Blogs / N+1 Abfragen in Django ORM: Finden und Eliminieren

N+1 Abfragen in Django ORM: Finden und Eliminieren

Veröffentlicht
3. September 2026
Autor
Faizan Nadeem
Tags
Django Python Backend Development
Luftaufnahme eines kreisförmigen Heckenlabyrinths in einem Park, mit konzentrischen Ringwegen, die auf einen kahlen Baum in der Mitte zulaufen
Foto von Ben Mathis Seibel auf Unsplash

Die größte Stärke des Django ORM ist zugleich seine größte Falle: order.customer.name liest sich wie ein kostenloser Attributzugriff, aber hinter diesem Punkt steckt die Entscheidung, ob Django diese Daten bereits im Speicher hat oder gleich eine brandneue SQL-Abfrage abschickt, um sie zu holen. Meistens feuert es diese Abfrage in einer Template-Schleife oder einem Serializer einmal pro Zeile ab — und weil jede einzelne Abfrage schnell ist, merkt es niemand, bis die Seite, die lokal 40 ms gebraucht hat, mit Produktivdaten plötzlich vier Sekunden braucht, weil es tausend statt zehn Zeilen sind.

Das ist genau die typische Form eines N+1-Problems: eine Abfrage, um eine Liste zu holen, und dann N weitere Abfragen — eine pro Element —, um für jede Zeile ein verknüpftes Objekt abzurufen. Das ist kein Bug in Django; es ist die natürliche Folge aus verzögertem Laden von Beziehungen kombiniert mit Code, der nie ausdrücklich sagt: „Lade die verknüpften Zeilen außerdem gesammelt.“ Die Lösung ist weder Rätselraten noch pauschales Überladen — sondern exakt zu identifizieren, welche Beziehung die wiederholten Abfragen auslöst, und dann aus einer kleinen Handvoll Werkzeugen genau das eine zu wählen, das zur tatsächlichen Form dieser Beziehung passt.

Sie lernen:

  • Wie Sie N+1-Abfragen in einem echten Request-Trace sehen, statt sie nur zu vermuten
  • Warum verzögerter Beziehungszugriff überhaupt N+1 verursacht
  • Wann select_related die richtige Lösung ist und warum es nicht für jeden Beziehungstyp funktioniert
  • Wann prefetch_related die richtige Lösung ist und was es intern tatsächlich anders macht
  • Wie Prefetch-Objekte gefilterte und sortierte verknüpfte Querysets behandeln
  • Wann keines der beiden Werkzeuge richtig ist und Raw-Aggregation die bessere Antwort ist
  • Wie Sie N+1-Regressionen in der CI fehlschlagen lassen, bevor sie die Produktion erreichen

Inhaltsverzeichnis

  1. Wie N+1 tatsächlich aussieht
  2. So sehen Sie es: Werkzeuge, um N+1 in freier Wildbahn zu finden
  3. Warum Lazy Loading das verursacht
  4. select_related: Für Vorwärts- und One-to-One-Beziehungen
  5. prefetch_related: Für Rückwärts- und Many-to-Many-Beziehungen
  6. Prefetch-Objekte: Filtern und Sortieren verknüpfter Daten
  7. Wenn beides nicht passt: Aggregation statt Iteration
  8. Regressionen in der CI erkennen
  9. Häufige Fallstricke

Wie N+1 tatsächlich aussieht

Nehmen wir eine Seite, die aktuelle Bestellungen zusammen mit dem Namen jedes Kunden auflistet:

orders = Order.objects.filter(status="pending")[:50]
for order in orders:
    print(order.customer.name)

Das sieht nach 51 harmlosen Codezeilen aus. Es sind 51 Abfragen: ein SELECT für das anfängliche orders-Queryset, dann ein zusätzliches SELECT ... WHERE id = ? für jeden einzelnen Zugriff auf order.customer, weil customer ein Foreign Key ist, den Django beim ersten Zugriff lazy lädt, nicht eager, als die Bestellung geholt wurde. Fünfzig Zeilen bedeuten fünfzig zusätzliche Roundtrips zur Datenbank, wobei jede einzelne den vollen Verbindungs- und Netzwerk-Overhead für einen Ein-Zeilen-Lookup zahlt, der auch ein einziger JOIN hätte sein können.

Genau dieses Muster aus Schleife plus Lookup taucht in einem Postgres-Plan als verschachtelte Schleife mit hoher Iterationszahl auf — siehe den Abschnitt zu Schleifen im EXPLAIN ANALYZE guide, um dasselbe Problem von der Datenbankseite statt von der ORM-Seite zu betrachten. Beides ist derselbe Bug in anderer Verpackung: eine billig wirkende Operation, die einmal pro Zeile wiederholt wird, statt einmal gesammelt für die gesamte Menge.

So sehen Sie es: Werkzeuge, um N+1 in freier Wildbahn zu finden

N+1 per Code-Review zu erraten, übersieht echte Fälle und produziert ständig False Positives — die einzige verlässliche Methode, sie zu finden, ist, sich die tatsächliche Abfrageanzahl für einen echten Request anzusehen.

Django Debug Toolbar ist lokal die schnellste Option — sie zählt im Panel jede pro Request ausgeführte Abfrage, zeigt doppelte Abfragen nebeneinander an und markiert sichtbar „similar queries“, wenn dieselbe Form von SELECT ... WHERE id = ? innerhalb einer Response wiederholt auftaucht.

django.test.utils.CaptureQueriesContext macht dasselbe programmatisch, was wichtig ist, weil es sich in Tests nutzen lässt und nicht nur in einer interaktiven Browser-Session:

from django.test.utils import CaptureQueriesContext
from django.db import connection

with CaptureQueriesContext(connection) as ctx:
    list(Order.objects.filter(status="pending")[:50])
    for order in Order.objects.filter(status="pending")[:50]:
        _ = order.customer.name

print(len(ctx.captured_queries))  # 51, not 2

django-silk und APM-Tools wie Sentrys Performance Monitoring oder Datadogs APM gehen in Produktion noch weiter — sie ordnen Abfrageanzahlen und kumulierte Zeit bestimmten Views und sogar einzelnen Zeilen zu. So entdeckt man N+1-Muster, die nur bei echtem Datenvolumen und echter Fan-out-Struktur von Beziehungen auftreten, nicht bei den kleinen Fixture-Daten, die lokal verwendet werden.

Das Signal, nach dem Sie in all diesen Tools suchen sollten, ist immer dasselbe: eine fast identische Abfrage, die sich nur in einem einzelnen WHERE-Klausel-Parameter unterscheidet und innerhalb eines Requests dutzende oder hunderte Male wiederholt wird. Diese Wiederholung — nicht allein die Gesamtzahl der Abfragen — ist der eigentliche Fingerabdruck von N+1.

Warum Lazy Loading das verursacht

Djangos ORM ist bewusst lazy aufgebaut — ein Queryset trifft die Datenbank erst, wenn darüber iteriert wird, und ein Related-Object-Descriptor trifft die Datenbank erst beim Zugriff. Das ist eine absichtliche und im Allgemeinen gute Voreinstellung: Sie bedeutet, dass Order.objects.filter(...) über mehrere Zeilen hinweg aufgebaut, herumgereicht und weiter gefiltert werden kann, ohne vorzeitig eine Abfrage auszulösen.

Die Kehrseite dieser Lazy-Eigenschaft ist, dass Django an der Stelle, an der Sie orders = Order.objects.filter(...) schreiben, nicht wissen kann, dass Ihre nächste Zeile auf jeder einzelnen Zeile .customer anfassen wird. Jeder Zugriff auf order.customer ist aus Djangos Sicht eine vollständig unabhängige Entscheidung — es erinnert sich nicht daran, dass es exakt dieselbe Art von Frage vor neunundvierzig Zeilen schon einmal beantwortet hat. select_related und prefetch_related existieren genau dafür, Django im Voraus zu sagen: „Du weißt bereits, dass ich diese verknüpften Daten für jede Zeile brauchen werde — hol sie jetzt gesammelt, in einer oder zwei Abfragen, statt darauf zu warten, Zeile für Zeile gefragt zu werden.“

Es lohnt sich, präzise zu sein, was tatsächlich gecacht wird, denn das ist eine häufige Quelle für Verwirrung. Sobald ein Queryset ausgewertet wurde (einmal iteriert, gesliced oder mit list() erzwungen), werden seine Ergebnisse auf dieser Queryset-Instanz gecacht — eine zweite Iteration feuert die Abfrage nicht erneut ab. Aber dieser Cache lebt auf dem Queryset-Objekt selbst, nicht auf den von ihm erzeugten Modellinstanzen, und er erstreckt sich überhaupt nicht auf Lookups verknüpfter Objekte. order.customer beim ersten Mal und order.customer beim zweiten Mal auf demselben order-Objekt nutzt zwar Djangos beziehungsbezogenen Cache pro Instanz und fragt deshalb nur einmal ab — aber ein frischer Aufruf von Order.objects.filter(...) einen Moment später beginnt wieder bei null, ohne Erinnerung daran, was das vorherige Queryset bereits geholt hat. Genau deshalb taucht N+1 speziell in Schleifen auf: Jede Iteration erzeugt eine eigene Modellinstanz mit eigenem leerem Beziehungscache, sodass sich der Vorteil „das habe ich schon gefragt“ nie über mehrere Zeilen hinweg aufsummiert.

Async Views und Query Counting

Djangos Async Views (async def get(self, request), verfügbar seit 4.1) ändern nichts an den obigen Mechanismen — select_related und prefetch_related funktionieren identisch, und mit sync_to_async umhüllte ORM-Aufrufe bündeln auf dieselbe Weise. Was sich ändert, ist, wie leicht ein N+1-Problem zu übersehen ist: Unter asyncio werden gleichzeitige Requests auf demselben Worker verschachtelt ausgeführt, sodass eine langsame, abfragelastige View nicht den ganzen Prozess blockiert wie eine synchrone, und eine Regression bei der Abfrageanzahl sich hinter scheinbar unauffälliger Wall-Clock-Latenz verstecken kann, bis die Parallelität steigt. Der Debugging-Ansatz bleibt unverändert — CaptureQueriesContext und assertNumQueries funktionieren unter async def-Test-Clients genauso — aber betrachten Sie Async als Grund, Abfrageanzahlen bewusster zu testen, nicht als Grund, warum sie weniger wichtig wären.

select_related funktioniert, indem es einen SQL-JOIN erzeugt und die Spalten der verknüpften Zeile in dieselbe Abfrage wie die Elternzeile zieht — ein Roundtrip, eine Ergebnismenge, verknüpfte Objekte bereits befüllt, wenn Sie darauf zugreifen.

orders = Order.objects.filter(status="pending").select_related("customer")
for order in orders:
    print(order.customer.name)  # no extra query — already joined

Das funktioniert nur für Beziehungen, bei denen jede Zeile genau eine verknüpfte Zeile zum Joinen hat: ForeignKey und OneToOneField, in „Vorwärts“-Richtung verfolgt (von Order zu seinem Customer, nicht umgekehrt). Sie können mehrere Hops in einem einzigen Aufruf verketten:

Order.objects.select_related("customer__account__billing_address")

Jeder zusätzliche Hop fügt der gleichen gejointen Abfrage weitere Spalten hinzu statt einer neuen Abfrage, deshalb bleibt es bei einem Roundtrip, egal wie tief die Kette geht — der Trade-off ist eine breitere Ergebnismenge pro Zeile, was dann relevant wird, wenn die gejointen Tabellen viele Spalten haben, die Sie tatsächlich gar nicht brauchen. select_related kann bei rückwärtigen Foreign Keys oder Many-to-Many-Beziehungen nicht helfen, weil ein JOIN, der pro Elternzeile mehrere verknüpfte Zeilen zurückgeben könnte, die natürliche Ein-Zeile-pro-Elternzeile-Form eines JOIN zerstört — genau für diesen Fall gibt es prefetch_related.

prefetch_related verfolgt eine komplett andere Strategie: Statt einer einzigen gejointen Abfrage führt es eine zweite, separate Abfrage aus, die alle verknüpften Zeilen für den gesamten Batch auf einmal holt, und heftet sie dann in Python an die richtigen Elternobjekte an.

customers = Customer.objects.filter(active=True).prefetch_related("orders")
for customer in customers:
    for order in customer.orders.all():  # no extra query per customer
        print(order.total)

Das sind insgesamt zwei Abfragen, egal ob es 10 Kunden oder 10.000 sind: ein SELECT * FROM customers WHERE active und ein SELECT * FROM orders WHERE customer_id IN (...), das jeden Kunden aus der ersten Ergebnismenge abdeckt. Django gruppiert dann die zweite Ergebnismenge in Python nach customer_id und hängt jede Bestellung an ihren passenden Kunden, sodass customer.orders.all() innerhalb der Schleife die Datenbank nie wieder berührt.

Das ist das richtige — und einzige — Werkzeug für rückwärtige Foreign Keys (customer.orders, also die „Viele“-Seite, die auf die „Eins“-Seite zurückblickt), Many-to-Many-Felder und jede Beziehung, bei der eine einzelne Elternzeile mehrere passende verknüpfte Zeilen haben kann — ein JOIN kann das nicht darstellen, ohne Elternzeilen zu duplizieren, daher ist der Ansatz von prefetch_related mit separater Abfrage plus Python-Zusammenführung strukturell notwendig und nicht bloß ein alternativer Stil. Die Wahl zwischen select_related und prefetch_related hängt vollständig von der Kardinalität der Beziehung ab: eine verknüpfte Zeile pro Elternobjekt bedeutet select_related; potenziell viele verknüpfte Zeilen pro Elternobjekt bedeutet prefetch_related.

select_relatedprefetch_related
Form der Beziehungeine verknüpfte Zeile pro Elternobjektnull, eine oder viele verknüpfte Zeilen pro Elternobjekt
Anwendbar aufForeignKey, OneToOneField (vorwärts)rückwärtiger FK, ManyToManyField, GenericRelation
Mechanismuseine Abfrage, SQL-JOINzwei (oder mehr) Abfragen, in Python zusammengeführt
Abfrageanzahlimmer 1 für die gesamte Kette1 + 1 pro vorab geladenem Relationship
Kann die verknüpfte Menge filtern/sortierennein (Join liefert vollständige Elternspalten)ja, über Prefetch-Objekte

Die letzte Zeile sollte man für sich allein verinnerlichen: select_related kann nicht einschränken, welche verknüpfte Zeile zurückkommt, weil ein JOIN entweder passt oder nicht — es gibt in einem einfachen Join kein Konzept von „gib mir nur den neuesten Treffer“. Immer wenn ein Related-Lookup seine eigene Filterung oder Sortierung benötigt, sind Sie im Gebiet von prefetch_related, selbst wenn die Beziehung nominell nur eine Zeile pro Elternobjekt hat, denn ein Prefetch-Objekt ist der einzige Mechanismus, der ein angepasstes Queryset akzeptiert.

prefetch_related("orders") holt alle verknüpften Bestellungen. Oft wollen Sie nur eine gefilterte oder sortierte Teilmenge — nur ausstehende Bestellungen oder nur die fünf neuesten — und ein einfaches String-Argument kann das nicht ausdrücken. Prefetch-Objekte können es:

from django.db.models import Prefetch

recent_pending = Prefetch(
    "orders",
    queryset=Order.objects.filter(status="pending").order_by("-created_at"),
    to_attr="recent_pending_orders",
)

customers = Customer.objects.prefetch_related(recent_pending)
for customer in customers:
    for order in customer.recent_pending_orders:  # already filtered, already ordered
        print(order.total)

Das Argument queryset lässt Sie innerhalb des Prefetch selbst filtern, sortieren oder sogar weiter mit select_related arbeiten — ein Prefetch kann ein select_related in sich verschachteln, um eine One-to-One-Beziehung abzudecken, die an einer Many-to-Many-Beziehung hängt, und das weiterhin in genau zwei Abfragen insgesamt. Das Argument to_attr speichert das gefilterte Ergebnis unter einem neuen Attributnamen, statt den Standard-Manager zu überschreiben. Das ist wichtig, weil die Wiederverwendung desselben Kundenobjekts an anderer Stelle im Request mit einem ungefilterten Aufruf von .orders.all() sonst stillschweigend eine brandneue, nicht-prefetchte Abfrage auslösen würde.

Wenn beides nicht passt: Aggregation statt Iteration

Manchmal ist das eigentliche Ziel gar nicht „gib mir jedes verknüpfte Objekt für jede Zeile“ — sondern eine einzelne Zahl oder eine kleine Menge von Zahlen pro Zeile, etwa eine Bestellanzahl oder eine Summe. Vollständige verknüpfte Objekte mit prefetch_related zu holen, nur um dann len() oder sum() in Python darauf anzuwenden, ist verschwendete Arbeit; die Datenbank kann dieses Aggregat direkt in einer einzigen Abfrage berechnen, ohne die einzelnen verknüpften Zeilen überhaupt als Python-Objekte zu materialisieren.

from django.db.models import Count, Sum

customers = Customer.objects.annotate(
    order_count=Count("orders"),
    lifetime_total=Sum("orders__total"),
)
for customer in customers:
    print(customer.order_count, customer.lifetime_total)  # no related objects fetched at all

Das ist eine wirklich andere Lösung als die beiden vorherigen, und genau die richtige, wenn das Endziel eine Zahl ist und nicht die verknüpften Zeilen selbst. annotate() verschiebt die Aggregation in die Datenbank — genau an den Ort, an dem GROUP BY und COUNT() diese Arbeit ohnehin effizient erledigen — statt jede verknüpfte Zeile über das Netzwerk zu ziehen, nur um sie in Python wieder zusammenzufalten.

Regressionen in der CI erkennen

Die beste Lösung für N+1 ist keine einmalige Aufräumaktion — sondern eine Regression bereits in einem Test fehlschlagen zu lassen, bevor sie ausgeliefert wird. django-test-plus’s assertNumQueries, oder Djangos eigene eingebaute Variante, pinnt die erwartete Abfrageanzahl für eine View oder einen Codepfad fest:

from django.test.utils import CaptureQueriesContext
from django.db import connection

def test_order_list_view_query_count(self):
    with self.assertNumQueries(2):
        response = self.client.get("/orders/")
        self.assertEqual(response.status_code, 200)

Ein solcher Test schlägt laut fehl, sobald jemand in einem Template oder Serializer einen neuen Zugriff auf order.customer.name hinzufügt, ohne gleichzeitig die passende select_related-/prefetch_related-Kette des entsprechenden Querysets zu aktualisieren — und fängt die Regression in einem zweisekündigen CI-Lauf ab statt Wochen später in einem Produktions-APM-Dashboard. Pinnen Sie die Anzahl zuerst bei den meistgenutzten Listen- und Detail-Views fest; dort richtet N+1-Fan-out pro Request den größten Schaden an, und dort amortisiert sich ein fixer Test am schnellsten.

Häufige Fallstricke

Fehler: select_related bei einem rückwärtigen Foreign Key verwenden. Django wirft ein FieldErrorselect_related kann einer Beziehung, auf die mehrere Zeilen passen könnten, tatsächlich nicht folgen. Lösung: Verwenden Sie für alles auf der „Viele“-Seite reflexartig prefetch_related.

Fehler: eine Beziehung vorab laden und sie dann in der Schleife erneut mit .filter() filtern. customer.orders.filter(status="pending") innerhalb einer Schleife nach prefetch_related("orders") löst pro Zeile eine brandneue Abfrage aus, weil ein frischer .filter()-Aufruf ein anderes Queryset ist als der gecachte Prefetch. Lösung: Wenden Sie den Filter von Anfang an im queryset-Argument eines Prefetch-Objekts an, und nutzen Sie to_attr, damit es keine Möglichkeit gibt, versehentlich auf einen ungefilterten, nicht-prefetchten Aufruf zurückzufallen.

Fehler: Überladen mit select_related über sehr breite Join-Ketten. select_related fünf Hops tief zu verketten zieht jede Spalte aus jeder Tabelle in der Kette in jede Zeile, selbst wenn tatsächlich nur ein einziges Feld aus der tiefsten Tabelle verwendet wird. Lösung: Verwenden Sie .only() zusammen mit select_related, um die gejointen Spalten einzuschränken, oder überdenken Sie, ob der tiefe Join für diese View überhaupt nötig ist.

Fehler: anzunehmen, eine niedrige Gesamtzahl an Abfragen bedeute, dass es kein N+1-Problem gibt. Zehn Abfragen für zehn Zeilen bestehen immer noch einen naiven Check wie „Abfragen unter irgendeinem Grenzwert“, obwohl es weiterhin ein echtes N+1-Muster ist, das sich beim Wachstum der Tabelle linear verschlechtert. Lösung: Achten Sie gezielt auf das Signal wiederholter Abfrageformen, nicht nur auf die rohe Anzahl, und testen Sie mit realistischen Zeilenzahlen statt mit Fixture-Größen.

Fehler: N+1 in der View zu beheben, aber nicht im Serializer. Django REST Framework-Serializer mit verschachtelten Beziehungen lösen dasselbe Lazy-Loading-Problem unabhängig davon erneut aus, was das Queryset der View bereits getan hat — ein CustomerSerializer mit verschachteltem OrderSerializer(many=True) feuert ohne Weiteres eine Abfrage pro Kunde ab, wenn das Queryset der View nie prefetch_related("orders") aufgerufen hat, ganz gleich, wie sorgfältig die View selbst geschrieben wurde. Lösung: Wenden Sie select_related/prefetch_related auf das Queryset an, das der Serializer tatsächlich konsumiert (gewöhnlich in get_queryset() des ViewSet), und verifizieren Sie das mit assertNumQueries gegen die serialisierte Response, nicht nur gegen das rohe Queryset — ein Serializer kann eigene Lazy-Zugriffspunkte einführen, die ein Test nur auf Queryset-Ebene nie erfassen würde.

Fazit

N+1-Abfragen sind kein Django-Bug, den man irgendwie umschiffen muss — sie sind die vorhersehbaren Kosten davon, dass Lazy Loading von Beziehungen auf Code trifft, der dem ORM nie sagt: „Bündle das.“ Die Lösung ist niemals „überall prefetch_related hinzufügen und hoffen“ — sondern die tatsächliche Kardinalität der Beziehung zu identifizieren (eine Zeile oder viele), sie passend auf select_related oder prefetch_related abzubilden, zu Prefetch-Objekten zu greifen, wenn die verknüpfte Menge Filterung oder Sortierung braucht, und zu erkennen, wann das eigentliche Ziel eine aggregierte Zahl war, die die Datenbank direkt berechnen sollte, statt eine Iteration, die Python von Hand erledigen muss.

Die zugrunde liegende Datenbank muss weiterhin jede Abfrageanzahl ausführen, die Ihr ORM-Code erzeugt. Sobald also die Abfrageanzahl selbst stimmt, lohnt es sich auch, den entstehenden Plan zu lesen — ein select_related-Join ist nur so schnell wie der Index, der ihn trägt, und genau dort setzt der EXPLAIN ANALYZE guide von hier aus an.

Haben Sie die Abfrageanzahl Ihrer meistgenutzten View schon gegen die tatsächlichen Zeilenzahlen in der Produktion geprüft — oder nur gegen Ihre lokalen Fixtures?

Weitere Artikel