CodeOath
← All posts
Python70 min total · 18 parts

Django Fundamentals: MVT Architecture, the ORM, and Middleware

Contents — Part 6 of 18: The QuerySet API and Laziness
Part 6 of 18 · ~2 min

The QuerySet API and Laziness

The single most important fact about Django's ORM: QuerySets are lazy — building one doesn't hit the database at all. A query only executes when the QuerySet is actually evaluated:

orders = Order.objects.filter(status="pending")   # no query yet — this just builds a QuerySet
orders = orders.filter(total__gt=100)               # still no query — chains onto the same QuerySet
orders = orders.order_by("-created_at")             # still no query

for order in orders:      # THIS triggers the query — iteration evaluates the QuerySet
    print(order.total)

QuerySets are evaluated by: iterating over them (for, list comprehension), calling len(), slicing with a step or converting to list(), calling bool() on them, or calling a method that must return a concrete result (.count(), .exists(), .get(), .first()). Chaining .filter() calls builds up one combined SQL WHERE clause — it does not run one query per .filter() call, which is a common misconception.

QuerySets are also cached — but only per-instance

orders = Order.objects.filter(status="pending")
list(orders)   # query #1 — evaluates and caches the results on this QuerySet object
list(orders)   # no new query — reuses the cached result set from the same object

# But a FRESH QuerySet re-queries, even for the "same" filter:
list(Order.objects.filter(status="pending"))  # a NEW query — different QuerySet object, no cache

This distinction — cached per QuerySet object, not globally — explains a subtle bug: if orders: ... for o in orders: ... triggers only one query total (bool-checking a QuerySet evaluates and caches it), while if Order.objects.filter(...): ... for o in Order.objects.filter(...): ... (rebuilding the QuerySet each time) triggers two separate queries.

Common QuerySet methods

Order.objects.filter(total__gt=100)              # WHERE total > 100
Order.objects.exclude(status="cancelled")         # WHERE status != 'cancelled'
Order.objects.get(pk=5)                           # exactly one row, or raises DoesNotExist / MultipleObjectsReturned
Order.objects.filter(status="pending").first()    # one row or None — never raises
Order.objects.filter(customer_name__icontains="ana")  # case-insensitive LIKE
Order.objects.values("status").annotate(count=models.Count("id"))  # GROUP BY status
Order.objects.aggregate(models.Sum("total"))      # a single dict, not a queryset
Order.objects.only("customer_name")               # SELECT only these columns
Order.objects.defer("notes")                      # SELECT all columns EXCEPT this one

.get() and .first() look similar but behave very differently on the failure paths: .get() raises Order.DoesNotExist if nothing matches and Order.MultipleObjectsReturned if more than one row matches, while .first() simply returns None if nothing matches and silently returns the first row (per current ordering) if there are several. Calling .get() on a query that might legitimately match zero or several rows, without catching the corresponding exception, is a common way to turn an expected "not found" case into an unhandled 500 error.