# Django und PostgreSQL 2026: Indizierung, Volltextsuche und Interviewfragen > Ein praxisnaher Leitfaden zur Django-PostgreSQL-Optimierung: B-Tree-, partielle und abdeckende Indizes, Volltextsuche mit SearchVector und GIN sowie Interviewfragen für 2026. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- Die PostgreSQL-Optimierung in Django entscheidet darüber, ob eine Anwendung über die ersten tausend Nutzer hinaus skaliert oder bei jeder Listenansicht ins Stocken gerät. PostgreSQL bringt eine Indizierungs-Engine und ein Subsystem für die Volltextsuche mit, die die meisten Django-Projekte ungenutzt lassen und damit Abfrageleistung verschenken, deren Rückgewinnung nichts kostet. Dieser Leitfaden behandelt die Indizierungsmuster, die Suchwerkzeuge von `django.contrib.postgres` und die Datenbank-Interviewfragen, die 2026 erfahrene von durchschnittlichen Django-Entwicklern unterscheiden. > **Erst messen, dann indizieren** > > Jeder Index beschleunigt Lesezugriffe, verlangsamt jedoch Schreibvorgänge und belegt Speicherplatz. Am besten wird `EXPLAIN (ANALYZE, BUFFERS)` auf der realen Abfrage ausgeführt, danach der Index hinzugefügt und der Plan verglichen. Ein Index, den der Query-Planner nie wählt, ist reiner Overhead bei jedem INSERT und UPDATE. ## Grundlagen der PostgreSQL-Indizierung in Django Standardmäßig legt PostgreSQL nur auf Primärschlüsseln und Spalten mit `unique=True` einen B-Tree-Index an. Jede andere gefilterte oder sortierte Spalte durchläuft einen sequenziellen Scan, solange kein Index existiert. Django stellt Indizes über `Meta.indexes` bereit, was gegenüber dem älteren `db_index=True` vorzuziehen ist, weil sich damit zusammengesetzte, partielle und abdeckende Indizes an einer einzigen Stelle definieren lassen. Die Spaltenreihenfolge in einem zusammengesetzten Index ist keine Kosmetik. Ein B-Tree kann einen Index für eine Abfrage nur nutzen, wenn der Filter ein führendes Präfix der indizierten Spalten verwendet, eine Regel, die auf [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys) ausführlich erläutert wird. Ein Index auf `(status, created_at)` bedient `WHERE status = ? ORDER BY created_at`, doch derselbe Index kann eine Abfrage, die allein nach `created_at` filtert, nicht beschleunigen. ```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 = [ # Einspaltiger B-Tree für exakte Suchen und Bereichsabfragen auf status models.Index(fields=["status"]), # Zusammengesetzter Index: bedient WHERE status = ? ORDER BY created_at DESC # Führende Spalte (status) muss im Filter vorkommen, damit er genutzt wird models.Index(fields=["status", "-created_at"]), ] ``` Der zusammengesetzte Index macht aus einer Dashboard-Abfrage wie „neueste ausstehende Bestellungen“ einen einzigen Index-Scan statt eines Filterns mit anschließendem Sortieren über die gesamte Tabelle. ## Partielle und abdeckende Indizes für gezielte Abfragen Ein partieller Index erfasst nur Zeilen, die einer Bedingung entsprechen, und bleibt dadurch klein und im Arbeitsspeicher. Wenn die meisten Zeilen denselben Wert teilen und Abfragen stets nur die Minderheit betreffen, ist ein partieller Index drastisch günstiger als ein vollständiger. Ein abdeckender Index geht noch weiter: Durch das Hinzufügen von Nicht-Schlüsselspalten mit `include` beantwortet PostgreSQL die Abfrage direkt aus dem Index, ohne auf den Tabellen-Heap zuzugreifen, ein Zugriffsmuster, das als Index-Only-Scan bezeichnet wird. ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # Felder wie oben definiert class Meta: indexes = [ # Partieller Index: indiziert nur ausstehende Zeilen und ignoriert die abgeschlossene Historie models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # Abdeckender Index: total wird direkt aus dem Index gelesen (Index-Only-Scan) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` In einer Bestelltabelle, in der 2 % der Zeilen ausstehend sind, kann der partielle Index fünfzigmal kleiner ausfallen als ein vollständiger Index auf derselben Spalte, und der Planner hält ihn im Buffer-Cache heiß. Die `include`-Klausel ist unter [PostgreSQL index-only scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) dokumentiert und stellt den am häufigsten übersehenen Vorteil in Django-Anwendungen dar, die eine numerische Spalte hinter einem Status-Filter aggregieren. ## Django-Volltextsuche mit PostgreSQL Der Griff zu `title__icontains=query` wirkt bequem, erzwingt aber einen sequenziellen Scan und kann Ergebnisse nicht nach Relevanz gewichten. Die Django-Volltextsuche kapselt die nativen Typen `tsvector` und `tsquery` von PostgreSQL über `SearchVector`, `SearchQuery` und `SearchRank`. Ein Suchvektor normalisiert Text zu Lexemen, entfernt Stoppwörter und reduziert Wörter auf ihren Wortstamm, sodass eine Suche nach „running“ auch „run“ und „ran“ findet. ```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 stuft Titeltreffer über Textkörpertreffer ein (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") ) ``` Der Parameter `weight` weist Prioritätsstufen von A (höchste) bis D zu. Ein Titeltreffer wird höher eingestuft als ein Treffer im Textkörper, selbst wenn beide den Suchbegriff enthalten, was dem entspricht, wie Nutzer eine Suche erwarten. Die vollständige API und das vierstufige Gewichtungsmodell behandelt die [Django full-text search reference](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/). ## GIN-Indizes und SearchVectorField für schnelle Suchen Die obige Abfrage berechnet den Suchvektor bei jeder Anfrage für jede Zeile neu, was bei einigen tausend Zeilen unproblematisch, bei einer Million jedoch inakzeptabel ist. Das Produktionsmuster speichert den Vektor in einem `SearchVectorField` und indiziert ihn mit einem GIN-Index, dem Indextyp, den PostgreSQL für zusammengesetzte Werte wie Volltextdokumente und JSONB verwendet. ```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() # Gespeichertes, vorberechnetes Suchdokument search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN verwandelt Volltextsuchen in logarithmische Index-Scans GinIndex(fields=["search_vector"]), ] ``` Das gespeicherte Feld muss mit `title` und `body` synchron bleiben. Ein `post_save`-Signal, das den Vektor mit `.update()` schreibt, vermeidet ein rekursives Auslösen des Signals, da `QuerySet.update()` keine Save-Signale aussendet. ```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"), ) ``` Für Teams mit Django 5.0 oder neuer entfernt ein datenbankseitig berechnetes `GeneratedField` oder ein PostgreSQL-Trigger das zusätzliche UPDATE vollständig, indem die Spalte innerhalb der Datenbank gepflegt wird. Unabhängig davon, welcher Mechanismus sie befüllt, filtert die Abfrage anschließend direkt das indizierte Feld, und der GIN-Index verwandelt einen einstigen vollständigen Tabellenscan in eine logarithmische Suche. > **GIN-Indizes sind teuer beim Schreiben** > > GIN-Indizes beschleunigen Lesezugriffe zu einem realen Schreibpreis, da jeder Insert viele Indexeinträge berührt. Bei Tabellen mit hohem Schreibaufkommen sollten der Speicherparameter `fastupdate` gesetzt und die Größe der Pending-List überwacht werden, sonst stauen sich Volltext-Schreibvorgänge hinter der Indexpflege. ## Den richtigen PostgreSQL-Indextyp wählen B-Tree ist die richtige Standardwahl, doch PostgreSQL stellt über `django.contrib.postgres.indexes` mehrere Indextypen bereit, und die falsche Wahl verschwendet Speicherplatz, ohne eine einzige Abfrage zu verbessern. Die Wahl richtet sich nach der Form der Daten und dem Operator, den die Abfrage verwendet, wie in der [PostgreSQL index types documentation](https://www.postgresql.org/docs/current/indexes-types.html) katalogisiert. | Indextyp | Am besten für | Django-Klasse | |------------|----------|--------------| | B-Tree | Gleichheit und Bereiche auf skalaren Spalten | `models.Index` | | GIN | Volltextsuche, JSONB, Array-Enthaltensein | `GinIndex` | | BRIN | Riesige Append-Only-Tabellen, nach einer Spalte geordnet | `BrinIndex` | | Hash | Reine Gleichheitssuchen auf großen Spalten | `HashIndex` | BRIN verdient bei Zeitreihendaten besondere Aufmerksamkeit. Bei einer Tabelle mit zig Millionen Zeilen, die in zeitlicher Reihenfolge eingefügt werden, belegt ein `BrinIndex` auf `created_at` wenige Kilobyte, während ein B-Tree Hunderte von Megabyte benötigen würde, denn BRIN speichert pro Blockbereich nur den Minimal- und Maximalwert. Der Kompromiss besteht darin, dass BRIN nur hilft, wenn die physische Zeilenreihenfolge der indizierten Spalte folgt, was bei Append-Only-Logs und Event-Tabellen von Natur aus zutrifft. ```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 = [ # Winziger Index für Bereichsscans auf einer append-only, zeitlich geordneten Tabelle BrinIndex(fields=["created_at"]), ] ``` Den Indextyp auf das Zugriffsmuster abzustimmen, ist ein wiederkehrendes Thema in Interviews für erfahrene Positionen, weil es beweist, dass ein Kandidat über das Speicherlayout nachdenkt, statt reflexartig überall B-Trees hinzuzufügen. ## Django-Datenbank-Interviewfragen im Jahr 2026 Datenbankfragen dominieren Django-Interviews für erfahrene Positionen, weil Gewandtheit im Umgang mit dem ORM noch kein Verständnis dafür bedeutet, was das ORM erzeugt. Die folgenden Fragen spiegeln wider, was Auswahlgremien 2026 tatsächlich prüfen, und sie ergänzen sich natürlich mit der abfrageseitigen Optimierung aus dem [Django ORM query optimization guide](/blog/django/django-orm-optimizing-queries). **Wann hilft ein zusammengesetzter Index auf `(a, b)` einer Abfrage nicht?** Wenn die Abfrage nicht nach `a` filtert. Ein B-Tree ist zuerst nach seiner führenden Spalte geordnet, sodass ein Filter allein auf `b` den Index nicht nutzen kann. Das ist die Leftmost-Prefix-Regel, und Kandidaten, die sie erklären, zeigen, dass sie die Indexstruktur verstehen und nicht bloß Rezepte auswendig gelernt haben. **Was ist der Unterschied zwischen `select_related` und `prefetch_related`?** `select_related` führt einen SQL-JOIN aus und funktioniert für Fremdschlüssel- und Eins-zu-eins-Beziehungen in einer einzigen Abfrage. `prefetch_related` setzt eine zweite Abfrage ab und verknüpft die Ergebnisse in Python, was für Viele-zu-viele- und umgekehrte Fremdschlüsselbeziehungen erforderlich ist. Die falsche Wahl verpasst entweder die Optimierung oder löst eine unnötige zweite Abfrage aus. **Wie unterscheidet sich `icontains` auf Datenbankebene von der Volltextsuche?** `icontains` wird zu `ILIKE '%term%'` kompiliert, was keinen gewöhnlichen B-Tree-Index nutzen kann und jede Zeile durchsucht. Die Volltextsuche gleicht gegen einen vorberechneten `tsvector` ab, der von einem GIN-Index gestützt wird, und gewichtet die Ergebnisse nach Relevanz. Diese Unterscheidung ist ein verlässliches Signal dafür, dass ein Kandidat über Spielzeugdatensätze hinausgekommen ist. **Warum kann `count()` bei einer großen Tabelle langsam sein, und welche Alternativen gibt es?** PostgreSQL führt keine zwischengespeicherte Zeilenanzahl für eine Tabelle, daher durchsucht `COUNT(*)` unter MVCC jede sichtbare Zeile. Für näherungsweise Zählungen liefert eine Abfrage von `pg_class.reltuples` sofort eine Schätzung, und für die Paginierung vermeidet Keyset-Paginierung auf einer indizierten Spalte das Zählen ganz. Strukturierte Übungen zu diesen Mustern bietet das [Django caching interview module](/technologies/django/interview-questions/django-caching), in dem cachegestützte Zähler ein wiederkehrendes Thema sind, sowie der gesamte [Django learning track](/technologies/django). **Was verrät `EXPLAIN ANALYZE`, das `EXPLAIN` allein nicht zeigt?** `EXPLAIN` gibt die vom Planner geschätzten Kosten aus; `EXPLAIN ANALYZE` führt die Abfrage tatsächlich aus und meldet reale Laufzeiten und Zeilenanzahlen. Eine große Lücke zwischen geschätzten und tatsächlichen Zeilen deutet auf veraltete Tabellenstatistiken hin, die sich mit `ANALYZE` beheben lassen, und ist eine häufige Ursache dafür, dass der Planner einen Index ignoriert, der offensichtlich passen sollte. ## Fazit - Indizes in `Meta.indexes` statt mit `db_index=True` definieren, damit zusammengesetzte, partielle und abdeckende Indizes an einer nachvollziehbaren Stelle liegen. - Spalten zusammengesetzter Indizes nach der Leftmost-Prefix-Regel ordnen: Die führende Spalte muss im Abfragefilter vorkommen, damit der Index greift. - Partielle Indizes einsetzen, wenn Abfragen stets nur eine Minderheit der Zeilen betreffen, etwa ausstehende Bestellungen in einer Tabelle, die von abgeschlossener Historie dominiert wird. - Abdeckende Indizes mit `include` hinzufügen, um Index-Only-Scans zu ermöglichen und bei leselastigen Aggregatabfragen den Heap-Zugriff zu vermeiden. - Die `icontains`-Suche durch `SearchVector`, `SearchQuery` und `SearchRank` ersetzen, um Relevanzgewichtung und Wortstammreduktion kostenlos zu erhalten. - Das Suchdokument in einem `SearchVectorField` speichern, das von einem `GinIndex` gestützt und durch ein Signal, ein `GeneratedField` oder einen Datenbank-Trigger synchron gehalten wird, bevor die Volltextsuche in den Produktionsmaßstab wächst. - Indexentscheidungen stets mit `EXPLAIN (ANALYZE, BUFFERS)` validieren; ein Index, den der Planner nie auswählt, ist eine Schreibsteuer ohne jeden Lesenutzen. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/de/blog/django/django-postgresql-indexing-full-text-search-2026