QuerySets: laziness, chaining, cloning, and caching
coreintermediateA QuerySet describes a query without running it — no SQL executes until something actually needs the results (iteration, list(), a print, or a method like get()/count()/exists()). Every filter()/exclude() call returns a brand-new, independent QuerySet (chaining, via cloning) rather than mutating the original. Once a QuerySet IS evaluated, its results are cached on that instance — re-iterating the same QuerySet object doesn't re-hit the database, but a fresh slice/index by itself does.
Think of it as
A QuerySet is a query BUILDER, not a query RESULT — closer to a SQL string being assembled than to a list already sitting in memory. Chaining works because each call (filter(), exclude(), order_by()) doesn't touch the earlier QuerySet at all; it clones it, adds one more clause, and hands back the clone — so `q1 = Entry.objects.filter(...)` and `q2 = q1.exclude(...)` are two genuinely separate objects, and refining q2 further never changes what q1 would return. Laziness means the actual SQL is deferred until the very last possible moment — the moment something needs real Python objects back, not database-query objects.
What we're doing: Build a filtered QuerySet across two chained calls, confirming that the second call does not mutate what the first would still return.
- 1
- q1 is a QuerySet object, not a result — no SQL has run yet.
- 2
- q1.exclude(...) clones q1 and adds one more clause, returning a NEW QuerySet (q2) — q1 itself is completely unaffected by this call.
- 4
- Evaluating q1 here confirms it still behaves as if q2 had never been built — proof that chaining clones rather than mutates.
Why this works: If exclude() mutated q1 in place instead of cloning it, building q2 from q1 would silently change what q1 itself returns later — a bug that would be very hard to trace, since nothing about the call q1.exclude(...) looks like it should affect q1. Cloning is what makes a single base QuerySet safely reusable as the starting point for several different, independent refinements.
Assuming a QuerySet's cache means slicing is also cheap on repeated access
Wrong
Better
What you see: A loop that repeatedly indexes into the same QuerySet object (queryset[i] for various i) issues a fresh database query on every single access, even though "the QuerySet caches its results" sounds like it should prevent that.
Why: The results cache is only populated once a QuerySet is evaluated AS A WHOLE (full iteration, list(), etc.) — slicing/indexing a QuerySet that hasn't been fully evaluated yet queries the database directly for that slice every time, bypassing the cache entirely. Converting to a real list first is what actually pays the query cost once and reuses it after.
- q1 = Entry.objects.filter(headline=...) — a QuerySet, not yet run
- leads to q2 = q1.exclude(pub_date__gte=today) (clone + exclude())
- leads to list(q1) (evaluated independently)
- q2 = q1.exclude(pub_date__gte=today) — a NEW, cloned QuerySet
- leads to list(q2) (evaluated independently)
- list(q1) — still just the original filter — unaffected
- list(q2) — the filter, minus the excluded rows
What triggers a QuerySet to actually run its SQL
Together
Remember: A QuerySet builds a query without running it; filter()/exclude() clone rather than mutate, so a base QuerySet can be safely reused for multiple independent refinements; evaluation (iteration, list(), count(), etc.) is what actually runs the SQL and populates the per-QuerySet cache — slicing/indexing alone bypasses that cache.
See also: retrieval methods · what triggers evaluation · the n plus 1 pattern

