PostgreSQL-indexen voor SaaS-apps: kies tussen B-tree, GIN en GiST met echte queryvormen zoals filters, zoeken, JSONB en arrays.

Een index verandert hoe PostgreSQL rijen vindt. Zonder index moet de database vaak een groot deel van de tabel lezen (een sequentiële scan) en vervolgens het meeste weggooien. Met de juiste index kan hij direct naar de overeenkomende rijen springen (een index lookup) en daarna alleen ophalen wat nodig is.
Je merkt dit vroeg in SaaS omdat dagelijkse schermen query-intensief zijn. Een enkele klik kan meerdere reads triggeren: de lijstpagina, een totale telling, een paar dashboard-kaartjes en een zoekveld. Wanneer een tabel groeit van duizenden naar miljoenen rijen, begint dezelfde query die eerst instant leek, te vertragen.
Een typisch voorbeeld is een Orders-pagina gefilterd op status en datum, gesorteerd op nieuwste eerst, met paginatie. Als PostgreSQL de hele orders-tabel moet scannen om betaalde bestellingen van de laatste 30 dagen te vinden, doet elke paginavraag extra werk. Een goede index verandert dat in een snelle sprong naar het juiste datasegment.
Indexen zijn niet gratis. Elke index geeft snellere reads voor specifieke queries, maar maakt ook writes langzamer (INSERT/UPDATE/DELETE moeten indexen bijwerken) en gebruikt meer opslag (plus meer cache-druk). Die afweging is waarom je bij echte querypatronen moet beginnen, niet bij indextypen.
Een simpele regel die onnodig werk voorkomt: voeg een index alleen toe wanneer je naar een specifieke, frequente query kunt wijzen die er sneller van wordt. Als je schermen bouwt met een chat-gedreven builder zoals Koder.ai, helpt het om de SQL achter je lijstpagina's en dashboards vast te leggen en dat als je index-wishlist te gebruiken.
De meeste verwarring over indexen verdwijnt wanneer je stopt met denken in features (JSON, search, arrays) en begint te denken in query-vorm: wat doet de WHERE-clausule en hoe verwacht je dat resultaten geordend zijn?
Gebruik B-tree wanneer je query eruitziet als normale vergelijkingen en je geeft om sortering. Het is het werkpaard voor gelijkheid, ranges en joins.
Voorbeeldvormen: filteren op tenant_id = ?, status = 'active', created_at >= ?, joinen users.id = orders.user_id, of “nieuwste eerst” tonen met ORDER BY created_at DESC.
GIN (Generalized Inverted Index) past goed wanneer één kolom veel leden bevat en je vraagt “bevat het X?”. Dat komt veel voor bij JSONB-sleutels, array-elementen en full-text vectors.
Voorbeeldvormen: metadata @> {'plan':'pro'} op JSONB, tags @> ARRAY['urgent'], of to_tsvector(body) @@ plainto_tsquery('reset password').
GiST (Generalized Search Tree) past bij vragen over afstand of overlap, waar waarden zich gedragen als ranges of vormen. Het wordt vaak gebruikt voor range-types, geometrische data en sommige “dichtstbijzijnde overeenkomst”-zoekopdrachten.
Voorbeeldvormen: overlappende tijdvensters met range-kolommen, sommige similarity-stijl zoekopdrachten (bijvoorbeeld met trigram-operators), of ruimtelijke queries (als je PostGIS gebruikt).
Een praktische manier om te kiezen:
Indexen versnellen reads, maar kosten schrijftijd en schijf. In SaaS doet die afweging het meest pijn op hete tabellen zoals events, sessions en activity logs.
De meeste SaaS-lijstschermen delen dezelfde vorm: een tenant-grens, een paar filters en een voorspelbare sortering. B-tree-indexen zijn hier de standaardkeuze en ze zijn meestal het goedkoopst in onderhoud.
Een veelvoorkomend patroon is WHERE tenant_id = ? plus filters zoals status = ?, user_id = ? en een tijdsbereik zoals created_at >= ?. Voor samengestelde B-tree-indexen zet je gelijkheidsfilters eerst (kolommen die je met = matcht), en voeg je daarna de kolom toe waarop je sorteert.
Regels die in de meeste apps goed werken:
tenant_id als elke query tenant-gescopeerd is.=-filters daarna (vaak status, user_id).ORDER BY-kolom als laatste (vaak created_at of id).INCLUDE om lijstpagina's te dekken zonder de sleutel breder te maken.Een realistisch voorbeeld: een Tickets-pagina die nieuwste items eerst toont, gefilterd op 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);
Die index ondersteunt zowel de filter als de sortering, zodat Postgres een grote resultset kan vermijden te sorteren. Het deel INCLUDE (title) helpt de lijstpagina minder tabelpagina's te laten aanraken, terwijl de indexsleutels gefocust blijven op filtering en ordening.
Voor tijdsintervallen geldt hetzelfde idee:
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);
Paginatie is waar veel SaaS-apps vertragen. Offset-paginatie (OFFSET 50000) dwingt de database om veel rijen voorbij te lopen. Seek-paginatie blijft snel door de laatst geziene sorteerkey te gebruiken:
SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
AND created_at < $2
ORDER BY created_at DESC
LIMIT 50;
Met de juiste B-tree-index blijft dit snel, zelfs als de tabel groeit.
De meeste SaaS-apps zijn multi-tenant: elke query moet binnen één tenant blijven. Als je indexen tenant_id niet bevatten, kan Postgres nog steeds rijen snel vinden, maar vaak door veel meer indexentries te scannen dan nodig. Tenant-aware indexen houden elke tenant's data bij elkaar in de index zodat veelgebruikte schermen snel en voorspelbaar blijven.
Een eenvoudige regel: zet tenant_id eerst in de index wanneer de query altijd filtert op tenant. Voeg daarna de kolom toe waarop je het meest filtert of sorteert.
Hoog-impact, saaie indexen zien er vaak zo uit:
(tenant_id, created_at) voor recente-items lijsten en cursor-paginatie(tenant_id, status) voor statusfilters (Open, Paid, Failed)(tenant_id, user_id) voor “items owned by this user” schermen(tenant_id, updated_at) voor “recent gewijzigd” admin-weergaven(tenant_id, external_id) voor lookups vanuit webhooks of importsOver-indexering gebeurt wanneer je voor elk licht verschillend scherm een nieuwe index toevoegt. Controleer vóór het aanmaken van nog een index of een bestaande samengestelde index al de linkse kolommen dekt die je nodig hebt. Bijvoorbeeld, als je (tenant_id, created_at) hebt, heb je meestal niet ook (tenant_id, created_at, id) nodig tenzij je echt op id filtert na die kolommen.
Partial indexes kunnen grootte en schrijfkosten verminderen wanneer de meeste rijen irrelevant zijn. Ze werken goed bij soft deletes en “only active” data, bijvoorbeeld: index alleen waar deleted_at IS NULL, of alleen waar status = 'active'.
Elke extra index maakt writes zwaarder. Inserts moeten elke index bijwerken, en updates kunnen meerdere indexen raken, zelfs als je maar één kolom verandert. Als je app veel events binnenkrijgt (inclusief apps snel gebouwd met Koder.ai), houd indexen gefocust op de paar queryvormen die gebruikers dagelijks raken.
JSONB is handig wanneer je app flexibele extra velden nodig heeft zoals feature flags, gebruikersattributen of per-tenant instellingen. Het nadeel is dat verschillende JSONB-operators zich verschillend gedragen, dus de beste index hangt af van hoe je queryt.
Twee vormen zijn het belangrijkst:
@>.-> / ->> (vaak vergeleken met =).Als je vaak filtert met @>, betaalt een GIN-index op de JSONB-kolom zich meestal terug.
-- 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);
Als je JSON-structuur voorspelbaar is en je meestal @> gebruikt op top-level keys, kan jsonb_path_ops kleiner en sneller zijn, maar het ondersteunt minder operator-types.
Als je UI herhaaldelijk filtert op één veld (zoals plan), dan is het extraheren van dat veld en het indexeren ervan vaak sneller en goedkoper dan een brede GIN.
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata->>'plan' = 'pro';
CREATE INDEX accounts_plan_expr
ON accounts ((metadata->>'plan'));
Een praktische regel: houd JSONB voor flexibele, zelden gefilterde attributen, maar promoot stabiele, veelgebruikte velden (plan, status, created_at) naar echte kolommen. Als je snel iterereert op een gegenereerde app, is het vaak een eenvoudige schema-aanpassing zodra je ziet welke filters op elke pagina terugkomen.
Voorbeeld: als je {"tags":["beta","finance"],"region":"us"} in JSONB opslaat, gebruik GIN wanneer je filtert op bundels attributen (@>), en voeg expressie-indexen toe voor de paar keys die de meeste lijstweergaven sturen (plan, region).
Arrays zijn verleidelijk omdat ze makkelijk op te slaan en te lezen zijn. Een users.roles text[] of projects.labels text[] kolom werkt goed wanneer je meestal één vraag stelt: bevat deze rij een waarde (of set waarden)? Dat is precies waar GIN helpt.
GIN is de standaardkeuze voor membership-queries op arrays. Het breekt de array in individuele items en bouwt een snelle lookup naar rijen die ze bevatten.
Array-queryvormen die vaak profiteren:
@> (array contains)&& (array deelt items)= ANY(...), maar @> is vaak voorspelbaarderEen typisch voorbeeld om gebruikers te filteren op rol:
-- Vind users die de "admin" rol hebben
SELECT id, email
FROM users
WHERE roles @> ARRAY['admin'];
CREATE INDEX users_roles_gin ON users USING GIN (roles);
En projecten filteren op een labelset (moet beide labels bevatten):
SELECT id, name
FROM projects
WHERE labels @> ARRAY['billing', 'urgent'];
CREATE INDEX projects_labels_gin ON projects USING GIN (labels);
Waar mensen verrast door worden: sommige patronen gebruiken de index niet zoals je verwacht. Als je de array naar een string converteert (array_to_string(labels, ',')) en vervolgens LIKE gebruikt, helpt de GIN-index niet. Ook als je “starts with” of fuzzy matches binnen labels nodig hebt, hoor je bij tekstzoekterrein, niet array membership.
Arrays kunnen ook moeilijk te onderhouden worden wanneer ze veranderen in een mini-database: frequente updates, metadata per item nodig (wie voegde het label toe, wanneer, waarom), of analytics per label. Op dat punt is een join-tabel zoals project_labels(project_id, label) meestal makkelijker te valideren, te queryen en te evolueren.
Voor zoekvelden komen twee patronen steeds terug: full-text search (vind records over een onderwerp) en fuzzy matching (omgaan met typefouten, gedeeltelijke namen en ILIKE-patronen). De juiste index is het verschil tussen “instant” en “time-outs bij 10k gebruikers”.
Gebruik full-text search wanneer gebruikers echte woorden typen en je resultaten op relevantie wilt rangschikken, zoals zoeken in tickets op onderwerp en beschrijving. De gebruikelijke setup is om een tsvector op te slaan (vaak in een gegenereerde kolom) en die te indexeren met GIN. Je zoekt met @@ en een tsquery.
-- Tickets: full-text search op 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%';
Wat je in de vector opslaat: alleen de velden die je daadwerkelijk doorzoekt. Als je alles (notes, interne logs) opneemt, betaal je in indexgrootte en schrijfkosten.
Gebruik trigram-similariteit wanneer gebruikers op namen, e-mails of korte zinnen zoeken en je gedeeltelijke matches of typo-tolerantie nodig hebt. Trigrams helpen bij ILIKE '%term%' en similarity-operators. GIN is meestal sneller voor “matcht het?” lookups; GiST kan beter passen wanneer je ook sorteert op gelijkenis.
Vuistregels:
tsvector voor relevantie-gebaseerde tekstzoekopdrachten.Valkuilen om op te letten:
ILIKE '%abc') dwingen scans af.Als je zoekschermen snel uitbrengt, behandel de index als onderdeel van de feature: zoek-UX en indexkeuze moeten samen ontworpen worden.
Begin met de exacte query die je app uitvoert, niet met een gok. Een “traag scherm” is meestal één SQL-statement met een heel specifieke WHERE en ORDER BY. Kopieer het uit logs, je ORM debug-output of welke query-captatie je ook gebruikt.
Een workflow die in echte apps werkt:
EXPLAIN (ANALYZE, BUFFERS) op diezelfde query.=, >=, LIKE, @>, @@), niet alleen op kolomnamen.EXPLAIN (ANALYZE, BUFFERS) opnieuw met realistische datavolumes.Hier is een concreet voorbeeld. Een Customers-pagina filtert op tenant en status, sorteert op nieuwste en pagineert:
SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Als EXPLAIN een sequentiële scan en een sort laat zien, lost een B-tree-index die filter en sortering matcht dat vaak op:
CREATE INDEX ON customers (tenant_id, status, created_at DESC);
Als het trage deel JSONB-filtering is zoals metadata @> '{"plan":"pro"}', dan wijst dat naar GIN. Als het full-text search is zoals to_tsvector(...) @@ plainto_tsquery(...), wijst dat ook naar een GIN-ondersteunde search-index. Als het een “closest match” of overlap-stijl operatoren zijn, is GiST vaak de juiste keuze.
Na het toevoegen van de index, meet de afweging. Controleer indexgrootte, insert- en update-tijd, en of het de top paar trage queries helpt of slechts een edge-case. In snel bewegende projecten (inclusief die gebouwd met Koder.ai) helpt deze nacheck om onnodige indexen te vermijden.
De meeste indexproblemen gaan niet over het kiezen van B-tree vs GIN vs GiST. Ze gaan over het bouwen van een index die er logisch uitziet, maar niet overeenkomt met hoe de app de tabel opvraagt.
Fouten die het meest pijn doen:
tenant_id en created_at, maar de index begint met created_at, kan de planner hem overslaan.status, is_active of een boolean helpt weinig omdat het te veel rijen matcht. Combineer het met een selectieve kolom (zoals tenant_id of created_at) of sla het over.ANALYZE niet recent heeft gedraaid, kan de planner slechte plannen kiezen zelfs wanneer de juiste index bestaat.Een concreet voorbeeld: je Invoices-scherm filtert op tenant_id en status, en sorteert dan op created_at DESC. Een index alleen op status helpt nauwelijks. Een betere match is een samengestelde index die begint met tenant_id, daarna status, daarna created_at (filter eerst, sortering laatst). Die ene verandering wint vaak van het toevoegen van drie aparte indexen.
Behandel elke index als een kostenpost. Hij moet zijn bestaansrecht verdienen in echte queries, niet alleen in theorie.
Indexwijzigingen zijn makkelijk te deployen en lastig ongedaan te maken als ze schrijflast toevoegen of een drukke tabel blokkeren. Behandel het als een kleine release.
Begin met beslissen wat je optimaliseert. Haal twee korte ranglijsten uit je logs of monitoring: de queries die het vaakst draaien, en de queries met de hoogste latency. Schrijf voor elk de exacte vorm op: filterkolommen, sorteerorde, joins en gebruikte operators (equals, range, IN, ILIKE, JSONB-operators, array contains). Dat voorkomt gokken en helpt je het juiste indextype te kiezen.
Pre-ship checklist:
Nadat je de index hebt toegevoegd, verifieer dat hij helpt in het echte plan. Draai EXPLAIN (ANALYZE, BUFFERS) op de exacte query en vergelijk vóór en na. Kijk daarna een dag in productie:
Als je met Koder.ai bouwt, is het de moeite waard om de gegenereerde SQL voor één of twee trage schermen naast de wijziging te bewaren, zodat de index overeenkomt met wat de app echt draait.
Stel je een veelvoorkomend admin-scherm voor: een Users-lijst met tenant-scoping, een paar filters, sorteren op laatst actief, en een zoekvak. Dit is waar indexen ophouden theorie te zijn en echte tijd besparen.
Drie query-vormen die je meestal ziet:
-- 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;
Een klein maar doelbewust indexset voor dit scherm:
(tenant_id, status, last_active_at DESC).tsvector-kolom met een GIN-index.GIN (metadata) wanneer je veel @> gebruikt, of een expressie B-tree zoals ((metadata->>'plan')) wanneer je vooral op één sleutel filtert.Gemengde behoeften zijn normaal. Als één pagina filters + search + JSON doet, voorkom dan dat je alles in één mega-index propt. Houd de B-tree voor sortering/paginatie en voeg dan één gespecialiseerde index (vaak GIN) toe voor het dure deel.
Vervolgstappen: kies één traag scherm, schrijf de top 2-3 queryvormen op en beoordeel elke index op doel (filter, sort, search, JSON). Als een index niet duidelijk aansluit op een echte query, laat hem dan vallen uit het plan. Als je snel iterereert met Koder.ai, doe deze review terwijl je nieuwe schermen toevoegt om index-sprawl te voorkomen terwijl je schema nog verandert.
Een index zorgt ervoor dat PostgreSQL overeenkomende rijen kan vinden zonder het grootste deel van de tabel te moeten lezen. Voor veelvoorkomende SaaS-schermen zoals lijsten, dashboards en zoekopdrachten kan de juiste index een trage sequentiële scan veranderen in een snelle lookup die veel beter schaalt naarmate de tabel groeit.
Begin met B-tree voor de meeste applicatiequeries: het is het beste voor =-filters, range-filters, joins en ORDER BY. Als je query vooral over containment gaat (JSONB, arrays) of tekstzoekopdrachten, dan is GIN meestal de volgende keuze; GiST gebruik je vaker voor overlap- en “dichtstbijzijnde” stijl queries.
Zet eerst de kolommen waarmee je = filtert en zet daarna de kolom waarop je sorteert als laatste. Die volgorde komt overeen met hoe de planner de index efficiënt kan doorlopen, zodat hij zowel kan filteren als rijen in de juiste volgorde kan teruggeven zonder extra sortering.
Als elke query gescoord is op tenant_id, dan zorgt tenant_id als eerste kolom ervoor dat de rijen van een tenant bij elkaar in de index staan. Dat vermindert meestal de hoeveelheid index- en tabeldata die PostgreSQL moet aanraken voor dagelijkse lijstpagina's.
INCLUDE laat je extra kolommen toevoegen zodat index-only reads mogelijk zijn voor lijstpagina's, zonder de indexkey breder te maken. Het is het nuttigst wanneer je filtert en sorteert op een paar kolommen maar ook nog een paar extra velden op het scherm toont.
Gebruik een partial index wanneer je alleen om een subset van rijen geeft, zoals “niet verwijderd” of “alleen actief”. Het houdt de index kleiner en goedkoper om te onderhouden, wat belangrijk is op drukke tabellen met veel inserts en updates.
Gebruik een GIN-index op de JSONB-kolom wanneer je vaak zoekt met containment zoals metadata @> '{"plan":"pro"}'. Als je meestal filtert op één of twee specifieke JSON-sleutels, is een expressie B-tree index op (metadata->>'plan') vaak kleiner en sneller.
GIN is een goede keuze wanneer je belangrijkste vraag is “bevat dit array X?” met operators als @> of &&. Als je per-item metadata nodig hebt, veel updates hebt of analytics per label/rol wilt doen, is een join-tabel meestal makkelijker te beheren en te indexeren.
Voor full-text search bewaar je een tsvector (vaak als een gegenereerde kolom) en indexeer je die met GIN, en zoek je met @@ voor relevantie-gebaseerde resultaten. Voor fuzzy matching zoals ILIKE '%naam%' en typefouttolerantie zijn trigram-indexen (vaak GIN) meestal het juiste gereedschap.
Kopieer de exacte SQL die je app uitvoert en draai EXPLAIN (ANALYZE, BUFFERS) om te zien waar de tijd naartoe gaat en of je scant, sorteert of dure filters uitvoert. Voeg de kleinste index toe die past bij de operators en de sorteerorde van de query, en draai daarna dezelfde EXPLAIN opnieuw om te bevestigen dat de index echt gebruikt wordt en het plan verbetert.