A QuerySet is a description of a query, not its result. Knowing exactly which operations trigger evaluation is what separates writing one efficient query from writing five accidental ones.
3 min read
qs = Book.objects.filter(published_year__gte=2020)
qs = qs.exclude(out_of_print=True)
qs = qs.order_by("-published_year")
# Still zero queries have hit the database.Chaining .filter(), .exclude(), .order_by(), and similar methods only builds up a description of the SQL to eventually run — internally, an unevaluated QuerySet is closer to a query plan than a result set. This is genuinely useful: it means you can build a query across several function calls, conditionally add filters, and pass the whole thing around, all without touching the database — the actual SQL is assembled once, right before it's needed, from every filter that got chained on.
A fixed list of operations force Django to run the query and fetch results:
for loop, or wrapping it in list(qs).qs[5] evaluates immediately and returns one object directly, not a queryset; qs[::2] also forces evaluation. Plain slicing without a step (qs[:5]) stays lazy, translated into SQL LIMIT/OFFSET instead of running in Python. Negative indexing (qs[-1]) doesn't work on an unevaluated queryset at all — Django raises ValueError: Negative indexing is not supported. — which is why .first() and .last() exist as dedicated methods instead: they translate directly into an ordered, LIMIT 1 query rather than relying on Python-style negative indices.len(qs) — forces full evaluation to count the results in Python.bool(qs) — e.g. if qs: — checks whether there's at least one result.repr(qs) — printing it in a shell, which is why QuerySets seem to "just work" when you inspect them interactively..count() and .exists() — these don't evaluate the full queryset into Python objects; they issue their own lightweight SQL (SELECT COUNT(*) / SELECT 1 ... LIMIT 1) and return immediately.books = Book.objects.filter(published_year=2024)
print(f"Found {len(books)} books") # evaluates the queryset, caches the results
for book in books: # uses the CACHED results — no second query
print(book.title)Once a QuerySet has been evaluated, Django caches the result internally, so re-iterating the same QuerySet object doesn't re-hit the database. The trap is when you don't realize you're building a new QuerySet each time:
def get_recent_books():
return Book.objects.filter(published_year=2024) # returns a fresh, unevaluated QuerySet every call
print(len(get_recent_books())) # query #1
for book in get_recent_books(): # query #2 — a completely different QuerySet object,
print(book.title) # nothing was cached between these two callsThe fix isn't a Django trick — it's ordinary code hygiene: evaluate once, store the result, reuse the Python list.
.count() vs len(qs) vs .exists() — the actual decisionUse .count() if you haven't touched the queryset yet and only need a number. Use len(qs) if you've already iterated it earlier in the same view — the data's already in memory, so a second .count() query would be strictly wasteful. Use .exists() for a plain yes/no question, since it's the only one of the three that can stop as soon as it finds a single matching row instead of processing all of them.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. After `qs = Book.objects.filter(...).exclude(...).order_by(...)` with none of it iterated yet, how many queries have hit the database?
2. What happens when you access `qs[-1]` on an unevaluated QuerySet?
3. In `print(len(get_recent_books())); for book in get_recent_books(): ...` where get_recent_books() returns a fresh filter() call each time, why does this run 2 queries instead of 1?
4. If you only need a yes/no answer to 'are there any matching rows,' why is .exists() better than len(qs) > 0?
Django