QuerySets are lazy — when Django actually hits the database
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
A QuerySet doesn't run anything by itself
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.
What actually triggers evaluation
A fixed list of operations force Django to run the query and fetch results:
- Iterating it — a
forloop, or wrapping it inlist(qs). - A single index, or slicing with a step —
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 SQLLIMIT/OFFSETinstead of running in Python. Negative indexing (qs[-1]) doesn't work on an unevaluated queryset at all — Django raisesValueError: Negative indexing is not supported.— which is why.first()and.last()exist as dedicated methods instead: they translate directly into an ordered,LIMIT 1query 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.
Why this matters: the same queryset, evaluated twice by accident
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 decision
Use .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.
Further reading
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?