# Django en PostgreSQL in 2026: indexering, full-text search en interviewvragen > Een praktische gids voor Django PostgreSQL-optimalisatie: B-tree-, partiële en covering-indexen, full-text search met SearchVector en GIN, plus interviewvragen voor 2026. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- Django PostgreSQL-optimalisatie bepaalt het verschil tussen een applicatie die voorbij de eerste duizend gebruikers doorschaalt en een die bij elke lijstweergave vastloopt. PostgreSQL levert een indexeringsengine en een subsysteem voor full-text search die de meeste Django-projecten onaangeroerd laten, waardoor querysnelheid verloren gaat die niets kost om terug te winnen. Deze gids behandelt de indexeringspatronen, de zoekfuncties van `django.contrib.postgres` en de database-interviewvragen die in 2026 het onderscheid maken tussen medior en senior Django-engineers. > **Eerst meten, dan indexeren** > > Elke index versnelt leesbewerkingen, maar vertraagt schrijfbewerkingen en verbruikt schijfruimte. Voer `EXPLAIN (ANALYZE, BUFFERS)` uit op de echte query, voeg de index toe en vergelijk vervolgens het plan. Een index die de query planner nooit kiest, is pure overhead bij elke INSERT en UPDATE. ## PostgreSQL-indexering: de basis in Django Standaard maakt PostgreSQL alleen een B-tree-index aan op primaire sleutels en kolommen met `unique=True`. Elke andere gefilterde of gesorteerde kolom voert een sequential scan uit totdat er een index bestaat. Django stelt indexen beschikbaar via `Meta.indexes`, wat de voorkeur verdient boven het oudere `db_index=True` omdat het samengestelde, partiële en covering-indexen op één plek ondersteunt. De volgorde van kolommen binnen een samengestelde index is niet cosmetisch. Een B-tree kan een index voor een query alleen benutten wanneer het filter een voorafgaand prefix van de geïndexeerde kolommen gebruikt, een regel die uitgebreid wordt uitgelegd op [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys). Een index op `(status, created_at)` bedient `WHERE status = ? ORDER BY created_at`, maar diezelfde index kan een query die alleen op `created_at` filtert niet versnellen. ```python # models.py from django.db import models class Order(models.Model): customer_email = models.EmailField() status = models.CharField(max_length=20) total = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # B-tree op één kolom voor exacte en range-lookups op status models.Index(fields=["status"]), # Samengestelde index: bedient WHERE status = ? ORDER BY created_at DESC # De eerste kolom (status) moet in het filter staan om benut te worden models.Index(fields=["status", "-created_at"]), ] ``` De samengestelde index maakt van een dashboardquery als "laatste openstaande bestellingen" één enkele index scan in plaats van een filter-en-sorteer over de hele tabel. ## Partiële en covering-indexen voor gerichte queries Een partiële index dekt alleen rijen die aan een voorwaarde voldoen, waardoor hij klein blijft en in het geheugen past. Wanneer de meeste rijen dezelfde waarde delen en queries alleen de minderheid raken, is een partiële index dramatisch goedkoper dan een volledige. Een covering-index gaat verder: door niet-sleutelkolommen toe te voegen met `include`, beantwoordt PostgreSQL de query rechtstreeks vanuit de index zonder de tabel-heap aan te raken, een toegangspatroon dat een index-only scan heet. ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # velden zoals hierboven gedefinieerd class Meta: indexes = [ # Partiële index: indexeert alleen openstaande rijen, negeert afgeronde historie models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # Covering-index: total wordt direct uit de index gelezen (index-only scan) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` In een bestellingentabel waar 2% van de rijen openstaand is, kan de partiële index vijftig keer kleiner zijn dan een volledige index op dezelfde kolom, en de planner houdt hem warm in de buffer cache. De `include`-clausule is gedocumenteerd onder [PostgreSQL index-only scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) en is de meest over het hoofd geziene winst in Django-applicaties die een numerieke kolom aggregeren achter een statusfilter. ## Django full-text search met PostgreSQL Grijpen naar `title__icontains=query` voelt handig, maar dwingt een sequential scan af en kan resultaten niet op relevantie rangschikken. Django full-text search omhult de native `tsvector`- en `tsquery`-types van PostgreSQL via `SearchVector`, `SearchQuery` en `SearchRank`. Een search vector normaliseert tekst naar lexemen, verwijdert stopwoorden en reduceert woorden tot hun stam, zodat een zoekopdracht naar "running" ook "run" en "ran" vindt. ```python # views.py from django.contrib.postgres.search import ( SearchVector, SearchQuery, SearchRank, ) from .models import Article def search_articles(query_text): query = SearchQuery(query_text, config="english") # Gewicht A rangschikt titeltreffers boven body-treffers (gewicht B) vector = ( SearchVector("title", weight="A") + SearchVector("body", weight="B") ) return ( Article.objects.annotate(rank=SearchRank(vector, query)) .filter(rank__gte=0.1) .order_by("-rank") ) ``` De `weight`-parameter kent prioriteitscategorieën toe van A (hoogste) tot D. Een titeltreffer scoort hoger dan een body-treffer, zelfs wanneer beide de zoekterm bevatten, wat aansluit bij hoe gebruikers verwachten dat een zoekopdracht zich gedraagt. De volledige API en het vierledige gewichtsmodel worden behandeld in de [Django full-text search reference](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/). ## GIN-indexen en SearchVectorField voor snel zoeken De bovenstaande query berekent de search vector bij elk verzoek opnieuw voor elke rij, wat prima is voor een paar duizend rijen en onacceptabel voor een miljoen. Het productiepatroon slaat de vector op in een `SearchVectorField` en indexeert die met een GIN-index, het indextype dat PostgreSQL gebruikt voor samengestelde waarden zoals full-text-documenten en JSONB. ```python # models.py from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex from django.db import models class Article(models.Model): title = models.CharField(max_length=255) body = models.TextField() # Opgeslagen, vooraf berekend zoekdocument search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN maakt van full-text-lookups logaritmische index scans GinIndex(fields=["search_vector"]), ] ``` Het opgeslagen veld moet synchroon blijven met `title` en `body`. Een `post_save`-signaal dat de vector schrijft met `.update()` voorkomt dat het signaal zichzelf recursief activeert, aangezien `QuerySet.update()` geen save-signalen uitzendt. ```python # signals.py from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from .models import Article @receiver(post_save, sender=Article) def sync_search_vector(sender, instance, **kwargs): Article.objects.filter(pk=instance.pk).update( search_vector=SearchVector("title", weight="A") + SearchVector("body", weight="B"), ) ``` Voor teams op Django 5.0 of nieuwer verwijdert een database-berekend `GeneratedField` of een PostgreSQL-trigger de extra UPDATE volledig door de kolom binnen de database bij te houden. Welk mechanisme de kolom ook gevuld houdt, de query filtert vervolgens rechtstreeks op het geïndexeerde veld, en de GIN-index maakt van wat een full table scan was een logaritmische lookup. > **GIN-indexen zijn duur om te schrijven** > > GIN-indexen versnellen leesbewerkingen tegen reële schrijfkosten, aangezien elke insert veel index-entries raakt. Stel op tabellen met veel schrijfverkeer de opslagparameter `fastupdate` in en bewaak de grootte van de pending-list, anders lopen full-text-schrijfbewerkingen vast achter indexonderhoud. ## Het juiste PostgreSQL-indextype kiezen B-tree is de juiste standaardkeuze, maar PostgreSQL biedt via `django.contrib.postgres.indexes` verschillende indextypes, en het verkeerde kiezen verspilt schijfruimte zonder ook maar één query te verbeteren. De keuze volgt de vorm van de data en de operator die de query gebruikt, zoals gecatalogiseerd in de [PostgreSQL index types documentation](https://www.postgresql.org/docs/current/indexes-types.html). | Indextype | Beste voor | Django-klasse | |------------|----------|--------------| | B-tree | Gelijkheid en range op scalaire kolommen | `models.Index` | | GIN | Full-text search, JSONB, array-containment | `GinIndex` | | BRIN | Enorme append-only-tabellen gesorteerd op een kolom | `BrinIndex` | | Hash | Uitsluitend gelijkheidszoekopdrachten op grote kolommen | `HashIndex` | BRIN verdient bijzondere aandacht voor time-series-data. In een tabel met tientallen miljoenen rijen die in tijdstempelvolgorde zijn ingevoegd, neemt een `BrinIndex` op `created_at` een paar kilobytes in beslag waar een B-tree honderden megabytes nodig zou hebben, omdat BRIN alleen de minimale en maximale waarde per blokbereik opslaat. De keerzijde is dat BRIN alleen helpt wanneer de fysieke rijvolgorde de geïndexeerde kolom volgt, wat vanzelf het geval is voor append-only-logs en event-tabellen. ```python # models.py from django.contrib.postgres.indexes import BrinIndex from django.db import models class Event(models.Model): payload = models.JSONField() created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # Minieme index voor range scans op een append-only, chronologisch geordende tabel BrinIndex(fields=["created_at"]), ] ``` Het indextype afstemmen op het toegangspatroon is een terugkerend thema in senior-interviews, omdat het aantoont dat een kandidaat redeneert over storage layout in plaats van reflexmatig overal B-trees toe te voegen. ## Django database-interviewvragen in 2026 Databasevragen domineren senior Django-interviews omdat vaardigheid met de ORM nog geen begrip inhoudt van wat de ORM daadwerkelijk uitstuurt. De onderstaande vragen weerspiegelen wat sollicitatiepanels in 2026 echt aftasten, en ze sluiten natuurlijk aan bij de tuning op queryniveau uit de [Django ORM query optimization guide](/blog/django/django-orm-optimizing-queries). **Wanneer helpt een samengestelde index op `(a, b)` een query niet?** Wanneer de query niet op `a` filtert. Een B-tree is eerst geordend op zijn eerste kolom, dus een filter op alleen `b` kan de index niet benutten. Dit is de leftmost-prefix-regel, en kandidaten die deze uitleggen tonen aan dat ze de indexstructuur begrijpen in plaats van recepten uit het hoofd te kennen. **Wat is het verschil tussen `select_related` en `prefetch_related`?** `select_related` voert een SQL JOIN uit en werkt voor foreign-key- en one-to-one-relaties in één enkele query. `prefetch_related` voert een tweede query uit en voegt de resultaten in Python samen, wat nodig is voor many-to-many- en reverse-foreign-key-relaties. De verkeerde kiezen mist ofwel de optimalisatie, ofwel veroorzaakt een onnodige tweede query. **Hoe verschilt `icontains` op databaseniveau van full-text search?** `icontains` compileert naar `ILIKE '%term%'`, wat geen standaard B-tree-index kan gebruiken en elke rij scant. Full-text search matcht tegen een vooraf berekende `tsvector` ondersteund door een GIN-index en rangschikt resultaten op relevantie. Dit onderscheid is een betrouwbaar signaal dat een kandidaat verder is gekomen dan speelgoeddatasets. **Waarom kan `count()` traag zijn op een grote tabel, en wat zijn de alternatieven?** PostgreSQL houdt geen gecacht rijaantal per tabel bij, dus `COUNT(*)` scant elke zichtbare rij onder MVCC. Voor benaderende aantallen levert een query op `pg_class.reltuples` direct een schatting op, en voor paginering vermijdt keyset-paginering op een geïndexeerde kolom het tellen volledig. Gestructureerde oefening op deze patronen is beschikbaar in de [Django caching interview module](/technologies/django/interview-questions/django-caching), waar cache-ondersteunde tellers een terugkerend thema zijn, en verspreid over het volledige [Django learning track](/technologies/django). **Wat onthult `EXPLAIN ANALYZE` dat `EXPLAIN` alleen niet doet?** `EXPLAIN` toont de geschatte kosten van de planner; `EXPLAIN ANALYZE` voert de query daadwerkelijk uit en rapporteert echte tijden en rijaantallen. Een groot verschil tussen geschatte en werkelijke rijen wijst op verouderde tabelstatistieken, oplosbaar met `ANALYZE`, en is een veelvoorkomende oorzaak van een planner die een index negeert die overduidelijk van toepassing zou moeten zijn. ## Conclusie - Definieer indexen in `Meta.indexes` in plaats van `db_index=True`, zodat samengestelde, partiële en covering-indexen op één controleerbare plek staan. - Orden de kolommen van een samengestelde index volgens de leftmost-prefix-regel: de eerste kolom moet in het queryfilter voorkomen om de index te laten werken. - Gebruik partiële indexen wanneer queries slechts een minderheid van de rijen raken, zoals openstaande bestellingen in een tabel die wordt gedomineerd door afgeronde historie. - Voeg covering-indexen toe met `include` om index-only scans mogelijk te maken en heap-toegang over te slaan bij leesintensieve aggregatiequeries. - Vervang zoeken met `icontains` door `SearchVector`, `SearchQuery` en `SearchRank` om gratis relevantierangschikking en stemming te krijgen. - Sla het zoekdocument op in een `SearchVectorField` ondersteund door een `GinIndex`, synchroon gehouden door een signaal, `GeneratedField` of database-trigger, voordat full-text search op productieschaal komt. - Valideer indexbeslissingen altijd met `EXPLAIN (ANALYZE, BUFFERS)`; een index die de planner nooit kiest, is een schrijfbelasting zonder leesvoordeel. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/django/django-postgresql-indexing-full-text-search-2026