prefetch_related(): separate queries and the prefetch cache
coreadvancedprefetch_related() fixes N+1 for "many" relationships (ManyToManyField, reverse ForeignKey) that select_related() cannot handle — instead of a JOIN, it runs a SECOND query for ALL the related objects at once, then joins the two result sets together in Python. That result is cached on each object — but calling .filter()/.all() AGAIN on the related manager after prefetching creates a brand-new query, silently bypassing the cache and undoing the optimization.
Think of it as
select_related()'s JOIN trick only works because a forward FK/OneToOne has exactly one related row — a M2M or reverse FK doesn't have that guarantee, so JOINing it in directly would multiply the base row once per match, corrupting the result shape. prefetch_related() sidesteps this by running a SECOND, separate query — 'get every related object for ALL these base objects, in one IN (...) query' — and then does the actual matching in PYTHON, attaching each base object's own slice of the second query's results to it. That Python-side matching is exactly what the cache IS — pizza.toppings.all() after prefetching doesn't re-query, it reads from the already-matched Python list. The moment a DIFFERENT queryset method is called on that manager (.filter(), a fresh .all() reassigned, etc.), that's a genuinely new query, not a read from the cache — the cache only serves the EXACT same access pattern.
What we're doing: Fetch every restaurant along with all its pizzas in exactly 2 queries, and confirm the prefetch cache is actually being used rather than re-queried per restaurant.
- 1
- reset_queries() clears the tracked log so the count below reflects only this block.
- 5
- r.pizzas.all() reads from the prefetch cache — despite running inside a loop over every restaurant, it adds ZERO new queries, unlike the same call without prefetch_related() applied first.
Why this works: The query count (2, not 1-per-restaurant) is the actual proof the optimization worked — the SECOND query fetched every pizza for every restaurant in this queryset at once (a single WHERE restaurant_id IN (...) query), and the loop afterward only ever reads the already-fetched, already-matched Python data.
Calling .filter() on a prefetched relation, silently bypassing the cache
Wrong
Better
What you see: A query count that "should" be 2 (base objects + one prefetch query) turns back into N+1, and it is easy to miss why, since prefetch_related() was applied and looks correct at a glance.
Why: pizza.toppings.filter(spicy=True) is a genuinely NEW QuerySet — Django has no way to know this particular filter matches what was already prefetched, so it always issues a fresh query rather than trying to filter the in-memory cache. The fix is to move the filtering INTO the prefetch itself, via a Prefetch() object with a custom queryset, so the already-filtered result set is what gets cached.
- Query 1: restaurants
- leads to Query 2: WHERE restaurant_id IN (...)
- Query 2: WHERE restaurant_id IN (...) — all matching pizzas, at once
- leads to Python matches each restaurant to its pizzas
- Python matches each restaurant to its pizzas
- leads to restaurant.pizzas.all()
- restaurant.pizzas.all() — reads the cache — no new query
select_related() vs prefetch_related()
Together
Remember: prefetch_related() runs a separate query for the related side and joins the results in Python — the fix for ManyToManyField/reverse FK, which select_related() cannot handle. The cached result only serves the EXACT same access pattern (.all()) — a fresh .filter()/.exclude() on the related manager always issues a new query, bypassing the cache entirely.
See also: the prefetch object · select related · the n plus 1 pattern

