Django

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.

Intermediate

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 for loop, or wrapping it in list(qs).
  • A single index, or slicing with a stepqs[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.

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 calls

The 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?