PostgreSQL index-tricks du inte visste om: Från Reddit till produktion
En Reddit-tråd med 192 poäng avslöjar index-misstag som kostar utvecklare timmar. Funktionella index, partiella index, INCLUDE, BRIN och hash-index — med kod du kan kopiera direkt.
Bild: Taylor Vick / Unsplash
En Reddit-tråd med 192 poäng på r/programming länkade till Jon Charters artikel ”Things you didn’t know about indexes” — och kommentarsfältet exploderade. Utvecklare delade indexeringsmisstag som kostat dem timmar, dagar, ibland veckor av felsökning. Samma mönster dök upp gång på gång: queries som ”borde” använda index men inte gör det.
Det här är inte ännu en ”vad är ett index”-guide. Om du behöver grunderna finns vår PostgreSQL-guide där. Det här handlar om de index-tekniker som erfarna utvecklare önskar att de känt till tidigare — och de misstag som är lättare att göra än du tror.
Varför ditt index inte används
Det vanligaste klagomålet i Reddit-tråden: ”Jag skapade ett index men Postgres använder det inte.” Före några index-tricks behöver vi förstå de tre vanligaste anledningarna.
1. Funktioner dödar index
Om du har ett index på name men filtrerar på lower(name) kan Postgres inte använda indexet. Databasen söker efter lower(name)-värden som inte finns i indexstrukturen. Charter formulerar det rakt: ”If the database can’t see the raw indexed column on the left side of the comparison, the index is off the table.”
-- Index på "name" hjälper INTE denna query:
SELECT * FROM users WHERE lower(name) = 'erik';
-- Postgres ser lower(name), inte name → Seq Scan
-- EXPLAIN avslöjar problemet:
EXPLAIN ANALYZE SELECT * FROM users WHERE lower(name) = 'erik';
-- → Seq Scan on users (cost=0.00..1842.00 rows=50 width=72)
-- Filter: (lower(name) = 'erik'::text)
-- Rows Removed by Filter: 99950Lösningen är ett funktionellt index — men det kommer vi till.
2. Implicit typkonvertering
Det här är än mer lumsk. Om kolumnen är integer men du jämför med en text-sträng, wrappar Postgres tyst kolumnen i en typkonvertering. Plötsligt används en funktion — och indexet ignoreras.
-- user_id är integer, men parametern skickas som text
SELECT * FROM orders WHERE user_id = '42';
-- Postgres kan lösa detta, men:
-- Om kolumnen är text och du skickar integer:
SELECT * FROM events WHERE event_code = 12345;
-- Postgres wrapper: WHERE event_code::integer = 12345
-- → Index på event_code (text) ignoreras!3. Kolumnordning i sammansatta index
Ett index på (type_1, type_2) sorterar först på type_1, sedan type_2 inom varje grupp. Filtrerar du bara på type_2? Då är indexet värdelöst — det vore som att söka i en telefonkatalog sorterad på efternamn när du bara har förnamnet.
-- Index: CREATE INDEX ON products (category, brand);
-- ✅ Använder index (matchar vänsterled):
SELECT * FROM products WHERE category = 'electronics';
SELECT * FROM products WHERE category = 'electronics' AND brand = 'samsung';
-- ❌ Kan INTE använda index effektivt:
SELECT * FROM products WHERE brand = 'samsung';
-- "brand" är det sekundära ledet → Seq ScanTumregel: sätt den mest selektiva equality-kolumnen först, sedan range- eller sorteringskolumner.
Funktionella index: Indexera uttryck
Istället för att indexera en rå kolumn kan du indexera resultatet av ett uttryck. Det löser lower()-problemet direkt:
-- Indexera det exakta uttrycket du använder i WHERE
CREATE INDEX idx_users_lower_name ON users (lower(name));
-- Nu matchar queryn:
SELECT * FROM users WHERE lower(name) = 'erik';
-- → Index Scan using idx_users_lower_name
-- Fler exempel:
CREATE INDEX idx_users_created_date ON users ((created_at::date));
CREATE INDEX idx_orders_year ON orders ((EXTRACT(YEAR FROM order_date)));
CREATE INDEX idx_products_price_vat ON products ((price * 1.25));Haki Benita tar det ett steg längre. Istället för att indexera en full timestamptz kan du indexera bara datumdelen — det ger ett 3x mindre index tack vare deduplicering:
-- Fullt timestamp-index: ~214 MB på en stor tabell
CREATE INDEX ON sales (sold_at);
-- Funktionellt index på bara datum: ~66 MB
CREATE INDEX ON sales ((date_trunc('day', sold_at AT TIME ZONE 'UTC')::date));
-- 3x mindre, och snabbare för date-baserade queriesVarning: Queryn måste använda exakt samma uttryck som indexet. sold_at::date och date_trunc('day', sold_at)::date är inte samma sak för query plannern.
Partiella index: Indexera bara det som behövs
Varför indexera en miljon rader när du bara frågar efter 500? Partiella index filtrerar bort rader vid skapandet:
-- Bara 3% av användarna är admins. Varför indexera alla?
CREATE INDEX idx_users_admin_email ON users (email)
WHERE role = 'admin';
-- Istället för ~50 MB index → ~1.5 MB
-- Snabbare lookups, mindre disk, mindre cache-tryck
-- E-commerce: bara obehandlade ordrar
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';
-- SaaS: bara aktiva konton
CREATE INDEX idx_accounts_active ON accounts (plan_type)
WHERE deleted_at IS NULL AND status = 'active';Charter använder ett Pokémon-exempel i sin artikel: av 920 Pokémon är bara ett fåtal legendariska. Ett partiellt index på WHERE is_legendary = true slösar inte utrymme på de övriga 900+.
Nyckelinsikt från communityn: Partiella index är speciellt värdefulla när fördelningen är skev — en liten delmängd querias ofta medan resten är historisk data som sällan berörs.
Covering index med INCLUDE
Normalt hittar ett index raden och går sedan till tabellen för att hämta övriga kolumner (”heap fetch”). Med INCLUDE kan du bära med extra kolumner direkt i indexet — så att Postgres aldrig behöver besöka tabellen alls.
-- Utan INCLUDE: Index Scan + Heap Fetch
CREATE INDEX idx_users_email ON users (email);
SELECT email, name FROM users WHERE email = 'erik@example.com';
-- Hittar raden via index → hämtar "name" från tabellen
-- Med INCLUDE: Index Only Scan (ingen tabell-access)
CREATE INDEX idx_users_email_incl ON users (email) INCLUDE (name);
SELECT email, name FROM users WHERE email = 'erik@example.com';
-- ALL data finns i indexet → snabbare
-- EXPLAIN visar skillnaden:
-- Utan: Index Scan using idx_users_email (heap fetches: 1)
-- Med: Index Only Scan using idx_users_email_incl (heap fetches: 0)Skillnaden mot ett sammansatt index (email, name): INCLUDE-kolumner sorteras inte och påverkar inte indexets ordning. Det betyder att du kan använda INCLUDE på datatyper som inte har B-tree-operatorklasser, och att ett UNIQUE-index behåller sin unikhet på de indexerade kolumnerna.
-- Unikt på email, men bär med name utan att bryta unikheten
CREATE UNIQUE INDEX idx_users_email_unique ON users (email) INCLUDE (name);
-- Jämför med:
CREATE UNIQUE INDEX idx_users_email_name ON users (email, name);
-- → Unikhet gäller (email, name) KOMBINATIONEN, inte email ensamtBRIN-index: När B-tree är overkill
B-tree är standardindexet och passar de flesta fall. Men för stora tabeller med naturligt ordnad data — tidstämplar, auto-inkrementerande ID:n, loggar — finns det ett radikalt mindre alternativ.
-- B-tree på en 100 GB tabell: ~2 GB index
CREATE INDEX idx_logs_btree ON logs (created_at);
-- BRIN på samma tabell: ~100 KB index
CREATE INDEX idx_logs_brin ON logs USING BRIN (created_at);
-- 20 000x mindre. Inte ett skrivfel.BRIN (Block Range INdex) lagrar ett min/max-värde per block-range istället för en post per rad. Om data är fysiskt ordnad på disk — vilket den ofta är för created_at-kolumner där nya rader alltid kommer sist — kan Postgres hoppa över hela block-ranges vid sökning.
Här är fällan: Om data inte är fysiskt ordnad är BRIN värdelöst. Infogar du rader med slumpmässiga tidstämplar spänner varje block-ranges min/max-värden över hela värderymden. Postgres måste läsa alla block ändå.
-- Kontrollera fysisk ordning med correlation:
SELECT correlation FROM pg_stats
WHERE tablename = 'logs' AND attname = 'created_at';
-- correlation ≈ 1.0 → data är ordnad → BRIN fungerar
-- correlation ≈ 0.0 → slumpmässig ordning → använd B-treeHash-index: Underviktat för equality-lookups
Sedan PostgreSQL 10 är hash-index WAL-loggade och crash-säkra. För rena equality-jämförelser (=) kan de vara mindre och snabbare än B-tree — särskilt på långa strängar:
-- B-tree index på URL-kolumn: ~154 MB
CREATE INDEX idx_urls_btree ON urls (url);
-- Hash index: ~32 MB (5x mindre)
CREATE INDEX idx_urls_hash ON urls USING HASH (url);
-- Lookup: 0.022ms vs 0.046ms (hash vs B-tree)
-- Källa: Haki Benita benchmarksBegränsning: Hash-index stödjer bara =. Inga range-queries (<, >, BETWEEN), ingen sortering, inga foreign keys. Använd dem för exakta matchningar på långa värden — URL:er, tokens, hash-strängar.
EXPLAIN är inte valfritt
Varje index-diskussion på Reddit slutar med samma budskap: gissa inte, mät. EXPLAIN ANALYZE är ditt primära verktyg för att verifiera att ett index faktiskt används:
-- Grundläggande: visa vad Postgres PLANERAR att göra
EXPLAIN SELECT * FROM users WHERE email = 'erik@example.com';
-- Med ANALYZE: kör queryn och visa FAKTISKA tider
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'erik@example.com';
-- Vad du letar efter:
-- ✅ "Index Scan" eller "Index Only Scan" → index används
-- ❌ "Seq Scan" → full table scan → index används INTE
-- Med BUFFERS: visa I/O-detaljer
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE email = 'erik@example.com';
-- Buffers: shared hit=3 → 3 sidor lästes från cache
-- Buffers: shared read=47 → 47 sidor lästes från diskEtt vanligt misstag: att skapa index och anta att de används. Postgres query planner är smart — den väljer Seq Scan om tabellen är liten nog, eller om den beräknar att indexet inte filtrerar bort tillräckligt många rader. Det är inte en bugg. Det är en optimering.
Checklista: Index i produktion
Baserat på både Charters artikel och Reddit-diskussionen, en sammanfattning av de viktigaste principerna:
- Varje index kostar vid writes. INSERT, UPDATE och DELETE måste uppdatera alla påverkade index. Skapa bara index som ger mätbar förbättring på dina faktiska queries.
- Kör EXPLAIN ANALYZE före och efter. Mät med produktionsliknande data. 100 rader beter sig annorlunda än 10 miljoner.
- Använd CREATE INDEX CONCURRENTLY i produktion. Standard
CREATE INDEXlåser tabellen för writes.CONCURRENTLYtar längre tid men blockerar inte. - Granska oanvända index regelbundet. Döda index kostar write-prestanda utan att bidra till reads.
- Testa med realistisk datavolym. Query planners ändrar strategi baserat på tabellstorlek och datadistribution.
-- Hitta oanvända index i din databas:
SELECT
schemaname || '.' || relname AS table,
indexrelname AS index,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size,
idx_scan AS times_used
FROM pg_stat_user_indexes i
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisunique
ORDER BY pg_relation_size(i.indexrelid) DESC;Nordisk vinkel: Varför det här är relevant för svenska utvecklare
Supabase, Neon och Railway — plattformarna som dominerar svenska startups — kör alla PostgreSQL. När din SaaS växer från 10 000 till 10 miljoner rader är det inte frameworks eller infrastruktur som blir flaskhalsen. Det är dina queries. Och queries fixas med rätt index.
Merparten av Nordens tech-scen bygger på PostgreSQL utan att någonsin öppna pg_stat_user_indexes eller köra EXPLAIN ANALYZE på sina tyngsta queries. Det är inte för att de är dåliga utvecklare — det är för att grunderna aldrig behövde optimeras när tabellerna var små. Men tabeller växer. Och när de gör det är det här artiklarna du önskar att du läst tidigare.
Källor och vidare läsning
- Jon Charter — Things you didn’t know about indexes
Originalartikeln som startade Reddit-diskussionen. Tydliga förklaringar med Pokémon-exempel.
jon.chrt.dev/2026/04/15/things-you-didnt-know-about-indexes.html - r/programming-diskussion (192 poäng)
Community-diskussion med insikter om kolumnordning, BRIN och partiella index.
reddit.com/r/programming/comments/1sm5d83/things_you_didnt_know_about_postgres_indexes/ - Haki Benita — Unconventional PostgreSQL Optimizations
Avancerade tekniker: funktionella index, hash-index och constraint exclusion med benchmarks.
hakibenita.com/postgresql-unconventional-optimizations - PostgreSQL dokumentation — Index Types
Officiell dokumentation för B-tree, Hash, GiST, SP-GiST, GIN och BRIN.
postgresql.org/docs/current/indexes-types.html