Filter concepts by levelShowing all levels.

Django · Section 17

Django ORM Fundamentals

Level
intermediate
Read
26 min
Concepts
3

How a QuerySet actually behaves — lazy, cloned rather than mutated on each chained call, evaluated only when something needs real results, and cached once fully evaluated — then the ten retrieval methods (split by what each returns: more QuerySet, a single object, or a number/boolean) and the five write methods, whose defining split is whether they go through save() (and everything tied to it — signals, auto_now) or bypass it entirely as direct SQL.

This section

What is true here

  1. A QuerySet is a query builder, not a result — no SQL runs until evaluation (iteration, list(), get(), count(), exists(), etc.).
  2. filter()/exclude() clone rather than mutate — a base QuerySet can be safely reused as the starting point for multiple independent refinements.
  3. get() raises for zero or multiple matches (use only where that would be a real bug); first()/last() return None instead for a normal "might not exist" case.
  4. exists() and count() both trigger a query but avoid loading full rows — exists() for presence, count() only when the number itself matters.
  5. update()/delete() operate as direct SQL against a QuerySet's matched rows, bypassing save() entirely — no signals, no auto_now fields, unless set explicitly in the call.

What you will be able to do

  • Reason correctly about when a QuerySet actually hits the database
  • Choose the right retrieval method for whether zero/multiple matches is an error or a normal case
  • Know exactly which write methods run save()/signals/auto_now and which bypass them
  • Use get_or_create()/update_or_create() correctly, keeping creation-only fields inside defaults

Laziness, chaining, and caching

What a QuerySet actually is, and exactly when it turns into a real SQL query.

QuerySets: laziness, chaining, cloning, and caching

coreintermediate

A QuerySet describes a query without running it — no SQL executes until something actually needs the results (iteration, list(), a print, or a method like get()/count()/exists()). Every filter()/exclude() call returns a brand-new, independent QuerySet (chaining, via cloning) rather than mutating the original. Once a QuerySet IS evaluated, its results are cached on that instance — re-iterating the same QuerySet object doesn't re-hit the database, but a fresh slice/index by itself does.

Think of it as

A QuerySet is a query BUILDER, not a query RESULT — closer to a SQL string being assembled than to a list already sitting in memory. Chaining works because each call (filter(), exclude(), order_by()) doesn't touch the earlier QuerySet at all; it clones it, adds one more clause, and hands back the clone — so `q1 = Entry.objects.filter(...)` and `q2 = q1.exclude(...)` are two genuinely separate objects, and refining q2 further never changes what q1 would return. Laziness means the actual SQL is deferred until the very last possible moment — the moment something needs real Python objects back, not database-query objects.

python
queryset = Model.objects.filter(a=1).exclude(b=2)   # built, not yet run
list(queryset)                                       # runs it, caches the result

What we're doing: Build a filtered QuerySet across two chained calls, confirming that the second call does not mutate what the first would still return.

shellpython
q1 = Entry.objects.filter(headline__startswith="What")
q2 = q1.exclude(pub_date__gte=today)

list(q1)   # entries starting with "What" — q1 was never touched by building q2
list(q2)   # the same starting entries, minus anything published today or later
1
q1 is a QuerySet object, not a result — no SQL has run yet.
2
q1.exclude(...) clones q1 and adds one more clause, returning a NEW QuerySet (q2) — q1 itself is completely unaffected by this call.
4
Evaluating q1 here confirms it still behaves as if q2 had never been built — proof that chaining clones rather than mutates.

Why this works: If exclude() mutated q1 in place instead of cloning it, building q2 from q1 would silently change what q1 itself returns later — a bug that would be very hard to trace, since nothing about the call q1.exclude(...) looks like it should affect q1. Cloning is what makes a single base QuerySet safely reusable as the starting point for several different, independent refinements.

Assuming a QuerySet's cache means slicing is also cheap on repeated access

Wrong

python
queryset = Entry.objects.all()
print(queryset[5])   # hits the database
print(queryset[5])   # hits the database AGAIN — same index, same object

Better

python
queryset = Entry.objects.all()
entries = list(queryset)   # evaluate once, populate the cache
print(entries[5])          # in-memory, no query
print(entries[5])          # still in-memory

What you see: A loop that repeatedly indexes into the same QuerySet object (queryset[i] for various i) issues a fresh database query on every single access, even though "the QuerySet caches its results" sounds like it should prevent that.

Why: The results cache is only populated once a QuerySet is evaluated AS A WHOLE (full iteration, list(), etc.) — slicing/indexing a QuerySet that hasn't been fully evaluated yet queries the database directly for that slice every time, bypassing the cache entirely. Converting to a real list first is what actually pays the query cost once and reuses it after.

Each chained call clones — the original is never mutated
clone +exclude()evaluatedindependentlyevaluatedindependently

q1 = Entry.objects.filter(headline=...)

a QuerySet, not yet run

q2 = q1.exclude(pub_date__gte=today)

a NEW, cloned QuerySet

list(q1)

still just the original filter — unaffected

list(q2)

the filter, minus the excluded rows

  • q1 = Entry.objects.filter(headline=...) — a QuerySet, not yet run
    • leads to q2 = q1.exclude(pub_date__gte=today) (clone + exclude())
    • leads to list(q1) (evaluated independently)
  • q2 = q1.exclude(pub_date__gte=today) — a NEW, cloned QuerySet
    • leads to list(q2) (evaluated independently)
  • list(q1) — still just the original filter — unaffected
  • list(q2) — the filter, minus the excluded rows

What triggers a QuerySet to actually run its SQL

What triggers a QuerySet to actually run its SQL
TriggerExample
Iterationfor entry in queryset: ...
Conversion to a list/boollist(queryset), bool(queryset)
Printing/reprprint(queryset)
A method returning a non-QuerySet value.get(), .count(), .exists(), .first()
NOT a trigger by itselfbuilding the QuerySet: Entry.objects.filter(...) alone runs nothing

Together

python
q = Entry.objects.filter(headline__startswith="What")   # no query yet
q = q.filter(pub_date__lte=today)                        # still no query — another clone
print(q)                                                  # NOW it runs

Remember: A QuerySet builds a query without running it; filter()/exclude() clone rather than mutate, so a base QuerySet can be safely reused for multiple independent refinements; evaluation (iteration, list(), count(), etc.) is what actually runs the SQL and populates the per-QuerySet cache — slicing/indexing alone bypasses that cache.

See also: retrieval methods · what triggers evaluation · the n plus 1 pattern

Advertisement

The retrieval methods

Ten ways to read data back, split by what each one returns and how it handles zero or multiple matches.

The retrieval methods

coreintermediate

all()/filter()/exclude() return QuerySets (chainable, lazy). get() returns exactly one object or raises (DoesNotExist / MultipleObjectsReturned) — never use it for a "might not exist" lookup. first()/last() return one object or None, no exception. earliest()/latest() need an ordering field. exists()/count() are the cheap way to check presence/size without loading actual rows.

Think of it as

These ten methods split into two families by what they hand back. all()/filter()/exclude() return MORE QUERYSET — still lazy, still chainable, nothing has run yet. get()/first()/last()/earliest()/latest()/exists()/count() all TRIGGER evaluation and return something else entirely: a single object, None, or a number — never a QuerySet, which is exactly why none of these can be chained further with another .filter(). get() is the strict member of that second family — it demands exactly one match and raises for zero or for more than one, which is the right tool only when "more than one match" would itself be a bug, not an expected outcome.

python
if Order.objects.filter(customer=customer).exists():   # cheap presence check
    ...
count = Order.objects.filter(status="PENDING").count()  # cheap size check

What we're doing: Check whether any pending orders exist without loading them, then separately fetch exactly one order by its unique reference, handling the not-found case explicitly.

orders/views.pypython
def has_pending_orders(customer):
    return Order.objects.filter(customer=customer, status="PENDING").exists()

def get_order_or_404_message(reference):
    try:
        return Order.objects.get(reference=reference)
    except Order.DoesNotExist:
        return None
2
exists() answers "is there at least one?" without ever loading a single Order into memory — cheaper than .count() > 0 (which counts every match) and much cheaper than list(...) (which loads every full row).
6
get() is correct here because reference is meant to be unique — MultipleObjectsReturned would itself indicate a real data-integrity bug worth surfacing loudly, not something to silently paper over.

Why this works: exists() and count() are both real queries, but neither transfers actual row data over the wire the way iterating a QuerySet or calling list() does — reaching for .exists() instead of bool(queryset) or len(list(queryset)) avoids loading data the caller was never going to use anyway.

Using get() where zero-or-more matches is a normal, expected outcome

Wrong

python
try:
    order = Order.objects.get(customer=customer, status="PENDING")
except Order.DoesNotExist:
    order = None
except Order.MultipleObjectsReturned:
    order = Order.objects.filter(customer=customer, status="PENDING").first()

Better

python
order = Order.objects.filter(customer=customer, status="PENDING").first()

What you see: A view has to catch TWO separate exceptions just to handle an entirely ordinary situation (a customer might have zero, one, or several pending orders) — extra code that first() would have made unnecessary from the start.

Why: get() is designed for lookups where more than one match indicates a genuine bug (a unique reference, a primary key) — using it against a filter that can legitimately match zero, one, or many rows (like "pending orders for this customer") forces the caller to handle MultipleObjectsReturned as if it were an error case, when first() already expresses "give me one if any exist" without ever raising for that situation.

What each method returns on zero matches
all()/filter()/exclude()
empty QuerySet — no error
get()
raises DoesNotExist
earliest()/latest()
raises DoesNotExist
first()/last()
returns None
exists()/count()
False / 0
  • all()/filter()/exclude(): returns a QuerySet, safe on zero — empty QuerySet — no error
  • get(): returns a value, raises on zero — raises DoesNotExist
  • earliest()/latest(): returns a value, raises on zero — raises DoesNotExist
  • first()/last(): returns a value, safe on zero — returns None
  • exists()/count(): returns a value, safe on zero — False / 0

The retrieval methods, by what they return

The retrieval methods, by what they return
MethodReturnsOn zero matches
all() / filter() / exclude()a QuerySet (still lazy)an empty QuerySet — no error
get()a single model instanceraises DoesNotExist
first() / last()a single instance or Nonereturns None
earliest() / latest()a single instanceraises DoesNotExist
exists()True / FalseFalse
count()an integer0

Together

python
Order.objects.filter(status="PENDING")            # QuerySet, still lazy
Order.objects.get(pk=1)                            # one Order, or raises
Order.objects.filter(status="PENDING").first()     # one Order or None
Order.objects.filter(status="PENDING").exists()    # True/False, cheap
Order.objects.filter(status="PENDING").count()     # an int, cheap

Remember: all()/filter()/exclude() stay lazy and chainable; get() demands exactly one match and raises otherwise (use it only where >1 would be a real bug); first()/last() return None instead of raising; exists()/count() are the cheap presence/size checks — exists() for yes/no, count() only when the actual number matters.

See also: laziness chaining and caching · write methods · values and values list

Advertisement

The write methods

Five ways to write data, split by whether they go through save() or bypass it as direct SQL.

The write methods

coreintermediate

update() writes new values to every row a QuerySet currently matches, in one SQL UPDATE — it does NOT call save() or send signals. delete() removes every matched row (plus CASCADEs). create() is get()-and-save() combined into one call. get_or_create()/update_or_create() both return a (object, created) tuple, and both use a transaction internally to reduce (not eliminate) race conditions between two concurrent callers.

Think of it as

update() and delete() operate directly on the DATABASE ROWS a QuerySet matches — they skip the Python model layer entirely, which is exactly why save()'s side effects (signals, custom save() logic, auto_now fields) never run for them. create()/get_or_create()/update_or_create() go the other way — they always end up calling a real .save() on a real instance, so all of that DOES run. The get_or_create() family's transaction wrapping narrows the race-condition window between "check if it exists" and "create it" but does not remove it entirely without a real database-level unique constraint backing it up.

python
obj, created = Model.objects.get_or_create(lookup_field=value, defaults={"other_field": x})
obj, created = Model.objects.update_or_create(lookup_field=value, defaults={"other_field": x})

What we're doing: Use update_or_create() to either create a customer's loyalty record or refresh its points total, in one call, distinguishing lookup fields from fields only set on write.

loyalty/services.pypython
def sync_loyalty_points(customer, points):
    record, created = LoyaltyAccount.objects.update_or_create(
        customer=customer,
        defaults={"points": points, "last_synced_at": timezone.now()},
    )
    return record, created
2
customer=customer is the LOOKUP — it identifies which row this is about, and is also set on the row if one is created.
3
defaults={...} are the fields written EITHER WAY — set on a fresh row if none existed, or overwritten on the existing row if one did. Fields outside defaults are used only for the lookup, never touched on update.

Why this works: Writing this as a manual try/get/except/create would need roughly the same number of lines but without the transaction wrapping update_or_create() provides internally — and would be easy to get subtly wrong (e.g. forgetting to also handle the update path when the object already exists, which update_or_create() guarantees by design).

Passing the same fields to both the lookup kwargs and defaults, causing get_or_create() to search on data meant only for creation

Wrong

python
Order.objects.get_or_create(reference="ORD-123", total=Decimal("50.00"))
# total is now part of the LOOKUP too — a row with reference="ORD-123" but a different
# total won't be found, and a duplicate row gets created instead

Better

python
Order.objects.get_or_create(
    reference="ORD-123", defaults={"total": Decimal("50.00")},
)

What you see: Calling get_or_create() a second time with a slightly different value for a field that should have been in defaults creates a SECOND row instead of finding and reusing the first one — a duplicate that violates the intended one-record-per-reference invariant.

Why: Every keyword argument NOT inside defaults becomes part of the lookup used to find an existing row — putting total directly as a kwarg means Django searches for a row matching BOTH reference AND that exact total, so any row with the same reference but a different total is treated as "not found," triggering an unwanted second creation instead of matching the existing row.

Which write methods actually run save()
update()
direct SQL UPDATE — no auto_now
delete() (QuerySet)
direct SQL DELETE + CASCADE
create()
Model(**kwargs) + save()
get_or_create() / update_or_create()
save() runs on create/update
  • update(): bypasses save()/signals, bulk (matched rows) — direct SQL UPDATE — no auto_now
  • delete() (QuerySet): bypasses save()/signals, bulk (matched rows) — direct SQL DELETE + CASCADE
  • create(): runs save()/signals, single instance — Model(**kwargs) + save()
  • get_or_create() / update_or_create(): runs save()/signals, single instance — save() runs on create/update

The write methods

The write methods
MethodRuns save()/signals?Returns
update()no — direct SQL UPDATEnumber of rows affected
delete() (QuerySet)no — direct SQL DELETE (+ CASCADE)(total_deleted, {label: count})
create()yesthe new instance
get_or_create()yes, if created(instance, created: bool)
update_or_create()yes, always(instance, created: bool)

Together

python
Order.objects.filter(status="PENDING").update(status="EXPIRED")   # bulk, no signals

order, created = Order.objects.get_or_create(
    reference="ORD-123", defaults={"customer": customer, "total": Decimal("50.00")},
)

Remember: update()/delete() are direct SQL against the QuerySet's matched rows — no save(), no signals, no auto_now, unless set explicitly. create()/get_or_create()/update_or_create() always go through save(). Only keyword args OUTSIDE defaults are used for the get_or_create()/update_or_create() lookup.

See also: retrieval methods · save and delete · constraints and indexes

Advertisement