The N+1 pattern, and how to detect it
coreintermediateN+1 is 1 query to fetch a list of objects, plus N more queries — one PER OBJECT — triggered by accessing a related field inside a loop. for entry in Entry.objects.all(): print(entry.blog) looks like ordinary code, but every entry.blog access inside the loop is a fresh, separate query, since ForeignKey access is lazy by default. The fix is always the same shape: tell Django up front (select_related()/prefetch_related()) which related data will be needed, so it's fetched in 1-2 queries total instead of N+1.
Think of it as
Every ForeignKey/relationship access on a model instance is, by default, its own lazy lookup — exactly like a fresh QuerySet, it does nothing until accessed, and THEN it runs its own query. That's completely invisible reading the code (entry.blog looks like a plain attribute access, indistinguishable from reading an already-loaded field) which is exactly why N+1 is easy to write and easy to miss in review — the bug isn't in any single line, it's in the multiplication effect of an innocent-looking line running once per loop iteration. Fixing it always means moving the related-data fetch OUTSIDE the loop, to query-build time, via select_related()/prefetch_related().
What we're doing: Confirm an N+1 suspicion using connection.queries before and after applying select_related(), rather than guessing.
- 1
- reset_queries() clears the tracked query log, so the count that follows reflects only the code under test.
- 5
- 11 queries for 10 entries confirms the N+1 pattern by measurement, not by guesswork — 1 (the entries) + 10 (one .blog lookup per entry).
- 9
- select_related("blog") collapses all 11 queries into 1 — the JOIN brings blog data along with each entry in the same round trip.
Why this works: connection.queries turns "I suspect this is slow" into "this measurably runs 11 queries, and here they are" — confirming the fix actually worked (1 query, not 11) is just as important as confirming the bug existed in the first place, since it is easy to add select_related() to the wrong relationship or spell it slightly wrong and see no improvement.
Adding select_related() to a query, but never actually confirming the query count dropped
Wrong
Better
What you see: select_related() is added, the code "looks" fixed, but a typo in the relationship name, or a relationship select_related() genuinely cannot handle (a reverse FK or M2M — needing prefetch_related() instead), leaves the exact same N+1 pattern running, undetected.
Why: select_related() silently accepts a wrong-but-plausible-looking field name (or one that just doesn't reduce anything, if applied to the wrong relationship type) without raising an error the way a genuine typo in a filter() lookup would — a real, measured query count before and after is the only reliable confirmation the fix actually worked, not just that the code compiles and runs.
- for entry in Entry.objects.all()
- 1 query for the list
- N more — one .blog access per entry
- Invisible in the code — looks like a plain attribute
- .select_related("blog")
- Exactly 1 query total
- blog data JOINed in up front
- Applied before the loop, at query-build time
Spotting and fixing N+1
Together
Remember: N+1 = 1 query for a list, plus 1 more PER ITEM from a related-field access inside a loop — invisible in the code, since the extra access looks like a normal attribute read. Confirm both the bug and the fix by measuring with connection.queries (or django-debug-toolbar), never by assumption — the fix is always select_related()/prefetch_related(), applied before the loop.
See also: hidden n plus 1 sources · select related · many to many and reverse fk

