CodeOath
← All posts
Python65 min total · 18 parts

Django Fundamentals: The ORM, Migrations, and Shipping a Real App

Part 6 of 18 · ~3 min

Querying ToolShed: QuerySets, and Why They Wait

The one fact about Django's ORM that everything else in this chapter hangs off: building a QuerySet never touches the database. The actual SQL only fires once something forces the QuerySet to produce real rows.

overdue_soon = Loan.objects.filter(status="approved")   # no query yet — just describes an intention
overdue_soon = overdue_soon.filter(due_date__lte=next_friday)  # still no query — same QuerySet, refined
overdue_soon = overdue_soon.order_by("due_date")                # still no query

for loan in overdue_soon:    # THIS is what fires the query — iteration forces evaluation
    send_reminder(loan)

A surprising number of ordinary-looking operations count as "forcing evaluation": looping over the rows, asking for len(), wrapping the whole thing in list(), even just checking whether it's truthy in an if. So does calling anything that has no way to answer without actually going to the database — .count(), .exists(), .get(), .first(). What none of these do, despite how it reads, is fire once per .filter() in a chain — three chained filters collapse into one WHERE clause carrying all three conditions, sent as a single round trip.

The cache lives on the object, not on the query

qs = Loan.objects.filter(status="requested")
list(qs)   # fires the query, and caches the resulting rows ON this QuerySet object
list(qs)   # no new query — the same object hands back its cached rows

# A DIFFERENT QuerySet, even with an identical filter, starts with no cache:
list(Loan.objects.filter(status="requested"))   # a fresh query — different object entirely

Verified directly: if pending_loans: ... for loan in pending_loans: ..., where pending_loans is one QuerySet reused for both the truthiness check and the loop, fires exactly one query — checking truthiness is itself an evaluation, and it caches. Rewrite that as if Loan.objects.filter(...): ... for loan in Loan.objects.filter(...): ..., building the filter fresh both times, and it fires two. Same logical filter, same eventual rows, genuinely different number of round trips — the difference is entirely about whether it's the same Python object doing both jobs.

The methods that show up constantly

Loan.objects.filter(status="approved")                    # WHERE status = 'approved'
Loan.objects.exclude(status="returned")                    # WHERE status != 'returned'
Loan.objects.get(pk=12)                                     # exactly one row, or an exception — see below
Loan.objects.filter(borrower=priya).first()                 # one row or None — never raises
Loan.objects.filter(tool__name__icontains="ladder")         # case-insensitive LIKE, across the FK
Loan.objects.values("status").annotate(n=models.Count("id"))  # GROUP BY status
Loan.objects.filter(status="approved").aggregate(models.Min("due_date"))  # a dict, not a queryset
Loan.objects.only("tool_id", "status")                       # SELECT just these columns
Loan.objects.defer("updated_at")                             # SELECT everything EXCEPT this column

.get() and .first() look interchangeable and aren't. .get() raises Loan.DoesNotExist when nothing matches and Loan.MultipleObjectsReturned when more than one row does — it's making a promise about cardinality, not just fetching a row. .first() never raises; a genuinely empty match just comes back as None. Write Loan.objects.get(pk=loan_id) in a view with no exception handling and the first time a neighbor bookmarks a loan's URL and that loan gets deleted later, the visit that used to 404 cleanly turns into an unhandled server error instead. The framework actually ships a shortcut built for exactly this case, and it's worth reaching for by default rather than writing the try/except yourself every time:

from django.shortcuts import get_object_or_404

def loan_detail(request, pk):
    loan = get_object_or_404(Loan, pk=pk)   # DoesNotExist becomes a clean 404, not a 500
    ...