PostgreSQL-index för SaaS-appar: välj mellan B-tree, GIN och GiST utifrån verkliga frågeformer som filter, sök, JSONB och arrayer.

Ett index ändrar hur PostgreSQL hittar rader. Utan ett index måste databasen ofta läsa stora delar av tabellen (en sekventiell skanning) och sedan kasta det mesta. Med rätt index kan den hoppa direkt till matchande rader (en indexuppslagning) och sedan hämta bara det som behövs.
Det märker du tidigt i SaaS eftersom vardagliga skärmar är query-tunga. Ett enda klick kan trigga flera läsningar: listvyn, en totalräkning, ett par dashboard-kort och en sökruta. När en tabell växer från tusentals till miljoner rader börjar samma fråga som en gång kändes omedelbar att bli seg.
Ett typiskt exempel är en Orders-sida filtrerad på status och datum, sorterad med nyast först och med pagination. Om PostgreSQL måste skanna hela orders-tabellen för att hitta betalda ordrar från de senaste 30 dagarna gör varje sidladdning extra arbete. Ett bra index förvandlar det till ett snabbt hopp till rätt skiva av data.
Index kostar dock. Varje index ger snabbare läsningar för specifika frågor, men gör också skrivningar långsammare (INSERT/UPDATE/DELETE måste uppdatera index) och använder mer lagring (plus mer cache-press). Den avvägningen är anledningen till att du bör börja från verkliga query-mönster, inte från index-typer.
En enkel regel som sparar jobb: lägg till ett index bara när du kan peka på en specifik, frekvent fråga som det kommer att snabba upp. Om du bygger skärmar med en chattdriven builder som Koder.ai hjälper det att fånga SQL bakom dina listvyer och dashboards och använda det som din index-önskelista.
Mycket av förvirringen kring index försvinner när du slutar tänka i funktioner (JSON, sök, arrayer) och börjar tänka i frågeform: vad gör WHERE-klausulen, och hur förväntar du dig att resultaten ska sorteras?
Använd B-tree när din fråga ser ut som normala jämförelser och du bryr dig om sorteringsordning. Det är vardagshästen för likhet, intervall och joins.
Exempel på former: filtrering med tenant_id = ?, status = 'active', created_at >= ?, join users.id = orders.user_id, eller att visa “nyast först” med ORDER BY created_at DESC.
GIN (Generalized Inverted Index) passar när en kolumn innehåller många medlemmar och du frågar “innehåller det X?”. Det är vanligt med JSONB-nycklar, array-element och fulltext-vektorer.
Exempel: metadata @> {'plan':'pro'} på JSONB, tags @> ARRAY['urgent'], eller to_tsvector(body) @@ plainto_tsquery('reset password').
GiST (Generalized Search Tree) passar frågor om avstånd eller överlappning, där värden beter sig som intervall eller former. Det används ofta för range-typer, geometriska data och vissa “närmast match”-sökningar.
Exempel: överlappande tidsfönster med range-kolumner, vissa likhetsbaserade sökningar (t.ex. med trigram-operatörer), eller spatiala frågor (om du använder PostGIS).
Ett praktiskt sätt att välja:
Index snabbar upp läsningar, men kostar skrivtid och disk. I SaaS spelar den avvägningen störst roll för heta tabeller som events, sessions och aktivitetsloggar.
De flesta SaaS-listvyer har samma form: en tenant-gräns, ett par filter och en förutsägbar sort. B-tree-index är standardvalet här och brukar vara billigast att underhålla.
Ett vanligt mönster är WHERE tenant_id = ? plus filter som status = ?, user_id = ? och ett tidsintervall som created_at >= ?. För sammansatta B-tree-index, sätt likhetsfilter först (kolumner du matchar med =), och lägg sedan kolumnen du sorterar på.
Regler som fungerar i de flesta appar:
tenant_id om varje fråga är tenant-scoped.=-filter nästa (ofta status, user_id).ORDER BY-kolumnen sist (ofta created_at eller id).INCLUDE för att täcka listvyer utan att göra nyckeln bredare.Ett realistiskt exempel: en Tickets-sida som visar nyaste först, filtrerad på status.
-- Query
SELECT id, status, created_at, title
FROM tickets
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
-- Index
CREATE INDEX tickets_tenant_status_created_at_idx
ON tickets (tenant_id, status, created_at DESC)
INCLUDE (title);
Det indexet stödjer både filter och sort, så Postgres kan undvika att sortera en stor resultmängd. INCLUDE (title) hjälper listvyn att röra färre table-sidor, samtidigt som indexnycklarna hålls fokuserade på filtrering och sortering.
För tidsintervall gäller samma idé:
SELECT id, created_at
FROM events
WHERE tenant_id = $1
AND created_at >= $2
AND created_at < $3
ORDER BY created_at DESC
LIMIT 100;
CREATE INDEX events_tenant_created_at_idx
ON events (tenant_id, created_at DESC);
Pagination är där många SaaS-appar blir långsamma. Offset-pagination (OFFSET 50000) tvingar databasen att gå förbi många rader. Seek-pagination håller sig snabb genom att använda den senast sedda sortnyckeln:
SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
AND created_at < $2
ORDER BY created_at DESC
LIMIT 50;
Med rätt B-tree-index förblir detta snabbt även när tabellen växer.
De flesta SaaS-appar är multi-tenant: varje fråga måste stanna inom en tenant. Om dina index inte inkluderar tenant_id kan Postgres fortfarande hitta rader snabbt, men den skannar ofta betydligt fler indexposter än nödvändigt. Tenant-aware index håller varje tenants data klustrad i indexet så vanliga skärmar förblir snabba och förutsägbara.
En enkel regel: sätt tenant_id först i indexet när frågan alltid filtrerar på tenant. Lägg sedan till kolumnen du oftast filtrerar eller sorterar på.
Högpåverkande, tråkiga index ser ofta ut så här:
(tenant_id, created_at) för listor med senaste objekt och cursor-pagination(tenant_id, status) för statusfilter (Open, Paid, Failed)(tenant_id, user_id) för “items som ägs av denna användare”-vyer(tenant_id, updated_at) för admin-vyer med “senast ändrade”(tenant_id, external_id) för uppslag från webhooks eller importerÖver-indexering händer när du lägger ett nytt index för varje liten varian av en skärm. Innan du skapar ett till, kolla om ett befintligt sammansatt index redan täcker de vänstermest kolumner du behöver. Till exempel, om du har (tenant_id, created_at) behöver du vanligtvis inte också (tenant_id, created_at, id) om du inte verkligen filtrerar på id efter de kolumnerna.
Partial index kan minska storlek och skrivkostnad när de flesta rader inte är relevanta. De fungerar bra för soft deletes och “endast aktiva”-data, till exempel: indexera bara där deleted_at IS NULL, eller bara där status = 'active'.
Varje extra index gör skrivoperationer tyngre. Inserts måste uppdatera varje index, och updates kan röra flera index även när du ändrar en kolumn. Om din app tar in många events (inklusive appar byggda snabbt med Koder.ai), håll indexen fokuserade på de få frågeformer användare träffar varje dag.
JSONB är praktiskt när din app behöver flexibla extrafält som feature flags, användarattribut eller per-tenant-inställningar. Fångsten är att olika JSONB-operatorer beter sig olika, så bästa indexet beror på hur du frågar.
Två former spelar störst roll:
@>.-> / ->> (ofta jämfört med =).Om du ofta filtrerar med @> lönar sig ofta ett GIN-index på JSONB-kolumnen.
-- Query shape: containment
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata @> '{"region":"eu","plan":"pro"}';
-- Index
CREATE INDEX accounts_metadata_gin
ON accounts
USING GIN (metadata);
Om din JSON-struktur är förutsägbar och du mest använder @> på top-level-nycklar kan jsonb_path_ops vara mindre och snabbare, men den stöder färre operatorer.
Om din UI upprepade gånger filtrerar på ett fält (som plan) är det ofta snabbare och billigare att extrahera det fältet och indexera det än att ha ett brett GIN.
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata->>'plan' = 'pro';
CREATE INDEX accounts_plan_expr
ON accounts ((metadata->>'plan'));
En praktisk regel: behåll JSONB för flexibla, sällan filtrerade attribut, men flytta stabila, höganvända fält (plan, status, created_at) till riktiga kolumner. Om du itererar snabbt på en genererad app är det ofta en enkel schema-ändring när du ser vilka filter som dyker upp på varje sida.
Exempel: om du sparar {"tags":["beta","finance"],"region":"us"} i JSONB, använd GIN när du filtrerar efter buntar av attribut (@>), och lägg till uttrycksindex för de få nycklar som driver de flesta listvyer (plan, region).
Arrayer är lockande eftersom de är lätta att lagra och läsa. En users.roles text[]-kolumn eller projects.labels text[] kan fungera bra när du mest frågar en sak: innehåller denna rad ett värde (eller en uppsättning värden)? Det är precis där GIN hjälper.
GIN är standardvalet för membership-queries på arrayer. Det bryter ner arrayen till individuella poster och bygger ett snabbt uppslag till rader som innehåller dem.
Array-frågeformer som ofta drar nytta:
@> (array contains)&& (array delar några items)= ANY(...), men @> är ofta mer förutsägbartEtt typiskt exempel för att filtrera användare efter roll:
-- Find users who have the "admin" role
SELECT id, email
FROM users
WHERE roles @> ARRAY['admin'];
CREATE INDEX users_roles_gin ON users USING GIN (roles);
Och filtrera projekt efter en label-set (måste inkludera båda labels):
SELECT id, name
FROM projects
WHERE labels @> ARRAY['billing', 'urgent'];
CREATE INDEX projects_labels_gin ON projects USING GIN (labels);
Där folk blir förvånade: vissa mönster använder inte indexet som du förväntar dig. Om du gör array_to_string(labels, ',') och sedan kör LIKE hjälper inte GIN-indexet. Och om du behöver “starts with” eller fuzzy-matches inuti labels är du i text-sök-territorium, inte array-membership.
Arrayer kan också bli svåra att underhålla när de förvandlas till en mini-databas: frekventa uppdateringar, behov av metadata per item (vem lade till labelen, när, varför), eller behov av analyser per label. Då är en join-tabell som project_labels(project_id, label) oftast lättare att validera, fråga och vidareutveckla.
För sökrutor dyker två mönster upp: full-text-sökning (hitta poster om ett ämne) och fuzzy-matching (hantera stavfel, partiella namn och ILIKE-mönster). Rätt index är skillnaden mellan “omedelbart” och “timeout vid 10k användare”.
Använd full-text när användare skriver riktiga ord och du vill ranka resultat efter relevans, som att söka tickets efter ämne och beskrivning. Vanlig setup är att lagra en tsvector (ofta i en generated column) och indexera den med GIN. Du söker med @@ och en tsquery.
-- Tickets: full-text search on subject + body
ALTER TABLE tickets
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(subject,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX tickets_search_vec_gin
ON tickets USING GIN (search_vec);
-- Query
SELECT id, subject
FROM tickets
WHERE search_vec @@ plainto_tsquery('simple', 'invoice failed');
-- Customers: fuzzy name search using trigrams
CREATE INDEX customers_name_trgm
ON customers USING GIN (name gin_trgm_ops);
SELECT id, name
FROM customers
WHERE name ILIKE '%jon smth%';
Vad du lagrar i vektorn: bara de fält du faktiskt söker. Om du inkluderar allt (anteckningar, interna loggar) betalar du i indexstorlek och skrivkostnad.
Använd trigram-similaritet när användare söker namn, e-post eller korta fraser och du behöver partiella träffar eller stavningstolerans. Trigram hjälper med ILIKE '%term%' och similarity-operatörer. GIN är vanligtvis snabbare för “matchar det?”-uppslag; GiST kan passa bättre när du också bryr dig om sortering efter likhet.
Tumregler:
tsvector för relevansbaserad text-sökning.Fallgropar att se upp för:
ILIKE '%abc') tvingar skanningar.Om du levererar sökskärmar snabbt, se indexet som en del av funktionen: sök-UX och indexval behöver designas tillsammans.
Börja med den exakta frågan din app kör, inte en gissning. En “långsam skärm” är vanligtvis ett SQL-statement med en mycket specifik WHERE och ORDER BY. Kopiera det från loggar, din ORM:s debug-output eller vad du än använder för query-fångst.
Ett arbetsflöde som håller i verkliga appar:
EXPLAIN (ANALYZE, BUFFERS) på samma fråga.=, >=, LIKE, @>, @@), inte bara kolumnnamnen.EXPLAIN (ANALYZE, BUFFERS) igen med realistisk datavolym.Här är ett konkret exempel. En Customers-sida filtrerar på tenant och status, sorterar på nyast och paginerar:
SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Om EXPLAIN visar en sekventiell skanning och en sort brukar ett B-tree-index som matchar filter och sort lösa det:
CREATE INDEX ON customers (tenant_id, status, created_at DESC);
Om det långsamma är JSONB-filtrering som metadata @> '{"plan":"pro"}' pekar det mot GIN. Om det är full-text som to_tsvector(...) @@ plainto_tsquery(...) pekar det också mot ett GIN-baserat sökindex. Om det är en “närmast match” eller överlappningsoperatorer är GiST ofta rätt.
Efter att ha lagt till indexet, mät avvägningen. Kolla indexstorlek, insert- och update-tid, och om det hjälper de topplånga frågorna eller bara ett edge-case. I snabbrörliga projekt (inklusive de byggda på Koder.ai) hjälper denna efterkontroll att undvika att stapla på sig oanvända index.
De flesta indexproblem handlar inte om att välja B-tree vs GIN vs GiST. De handlar om att bygga ett index som ser rätt ut, men som inte matchar hur appen frågar tabellen.
Misstag som brukar skada mest:
tenant_id och created_at, men indexet börjar med created_at, kan planner hoppa över det.status, is_active eller en boolean hjälper sällan eftersom det matchar för många rader. Para ihop med en selektiv kolumn (som tenant_id eller created_at) eller skippa det.ANALYZE inte körts nyligen, kan planner välja dåliga planer även när rätt index finns.Ett konkret exempel: din Invoices-sida filtrerar på tenant_id och status, sedan sorterar på created_at DESC. Ett index enbart på status hjälper knappt. En bättre match är ett kompositindex som börjar med tenant_id, sedan status, sedan created_at (filter först, sort sist). Den enda ändringen slår ofta ut tre separata index.
Behandla varje index som en kostnad. Det måste tjäna sina poäng i verkliga frågor, inte bara i teorin.
Indexändringar är lätta att deploya och jobbiga att backa om de ökar skrivkostnad eller låser en upptagen tabell. Innan du merge: behandla det som en liten release.
Börja med att bestämma vad du optimerar. Ta ut två korta rankningar från loggar eller övervakning: de frågor som körs oftast, och de frågor med högst latens. För varje fråga, skriv ner den exakta formen: filterkolumner, sort-order, joins och använda operatorer (equals, range, IN, ILIKE, JSONB-operatorer, array contains). Det förhindrar gissningar och hjälper dig välja rätt index-typ.
Pre-ship-checklista:
Efter att du lagt till indexet, verifiera att det hjälpte i den verkliga planen. Kör EXPLAIN (ANALYZE, BUFFERS) på den exakta frågan och jämför före/efter. Sedan övervaka produktionen i en dag:
Om du bygger med Koder.ai är det värt att behålla genererad SQL för en eller två långsamma skärmar tillsammans med förändringen, så indexet matchar vad appen faktiskt kör.
Föreställ dig en vanlig admin-sida: en Users-lista med tenant-scope, några filter, sort på senaste aktivitet och en sökruta. Här slutar index att vara teori och börjar spara verklig tid.
Tre frågeformer du oftast ser:
-- 1) List page with tenant + status filter + sort
SELECT id, email, last_active_at
FROM users
WHERE tenant_id = $1 AND status = $2
ORDER BY last_active_at DESC
LIMIT 50;
-- 2) Search box (full-text)
SELECT id, email
FROM users
WHERE tenant_id = $1
AND to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'')) @@ plainto_tsquery($2)
ORDER BY last_active_at DESC
LIMIT 50;
-- 3) Filter on JSON metadata (plan, flags)
SELECT id
FROM users
WHERE tenant_id = $1
AND metadata @> '{"plan":"pro"}'::jsonb;
En liten men genomtänkt indexuppsättning för den här skärmen:
(tenant_id, status, last_active_at DESC).tsvector-kolumn med GIN-index.GIN (metadata) när du använder @> mycket, eller ett uttrycks-B-tree som ((metadata->>'plan')) när du mest filtrerar på en nyckel.Blandade behov är normala. Om en sida gör filter + sök + JSON, undvik att klämma allt i ett mega-index. Behåll B-tree för sortering/pagination, och lägg sedan till ett specialiserat index (ofta GIN) för den dyra delen.
Nästa steg: välj en långsam skärm, skriv ner dess 2–3 vanligaste frågeformer, och granska varje index efter syfte (filter, sort, sök, JSON). Om ett index inte tydligt matchar en verklig fråga, ta bort det från planen. Om du itererar snabbt på Koder.ai, gör den här granskningen när du lägger till nya skärmar för att undvika index-sprawl medan schemat fortfarande ändras.
Ett index låter PostgreSQL hitta matchande rader utan att läsa större delen av tabellen. För vanliga SaaS-skärmar som listor, dashboards och sökningar kan ett rätt index vända en långsam sekventiell sökning till en snabb uppslagning som skalar bättre när tabellen växer.
Börja med B-tree för de flesta appfrågor — det passar =-filter, intervallfilter, joins och ORDER BY. Om frågan handlar om innehåll/containment (JSONB, arrayer) eller text-sökning är GIN ofta nästa steg; GiST används mer för överlappning och “närmaste”-typer av sökningar.
Sätt kolumner du filtrerar med = först, och lägg kolumnen du sorterar på sist. Den ordningen matchar hur planen kan gå igenom indexet effektivt så att den både filtrerar och returnerar rader i rätt ordning utan extra sort.
Om varje fråga alltid är scoped per tenant_id så är det nästan alltid värt att lägga tenant_id först. Det håller varje tenants rader grupperade i indexet och minskar mängden index- och tabelldata PostgreSQL måste besöka för vanliga listvyer.
INCLUDE låter dig lägga till extra kolumner så att index-only reads kan användas för listvyer utan att göra indexnyckeln bredare. Det är mest användbart när du filtrerar och sorterar på ett par kolumner men också visar några extra fält på skärmen.
Använd en partial index när du bara bryr dig om en delmängd av rader, exempelvis “inte borttagen” eller “endast aktiva”. Det håller indexet mindre och billigare att underhålla, vilket är viktigt på heta tabeller med många inserts och updates.
Använd GIN på JSONB när du ofta frågar med containment som metadata @> '{"plan":"pro"}'. Om du mest filtrerar på enstaka nycklar är en uttrycks-index (expression B-tree) på exempelvis (metadata->>'plan') ofta mindre och snabbare.
GIN passar när huvudfrågan är “innehåller den här arrayen X?” med operatorer som @> eller &&. Om du behöver metadata per element, frekventa uppdateringar eller analys per label/roll är en join-tabell oftast enklare att underhålla och indexera väl.
För full-text-sökning: lagra en tsvector (gärna som en generated column) och indexera den med GIN, och fråga med @@ för relevansbaserad sökning. För fuzzy-matching och ILIKE '%term%' använd trigram-index (ofta GIN) för delsträngar och stavningsfelstolerans.
Kopiera den exakta SQL-frågan som appen kör och kör EXPLAIN (ANALYZE, BUFFERS) för att se var tiden går — scanning, sortering eller dyra filter. Lägg till det minsta indexet som matchar frågans operatorer och sort-order, kör samma EXPLAIN igen för att bekräfta att det används och förbättrar planen.