QuerySet Deep Dive: Q, F & Lookups
A QuerySet is a lazy, chainable collection of database rows that Django builds up as Python objects and only sends to the database as a single SQL query when you finally iterate, slice, or evaluate it.
Learn QuerySet Deep Dive: Q, F & Lookups in our free Django course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a…
Part of the free Django course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
In this lesson you'll master the tools that make QuerySets powerful: field lookups like __gte and __icontains, Q objects for OR/AND/NOT logic, F expressions for field-to-field comparisons and atomic updates, and the laziness that ties it all together.
A field lookup is the double-underscore suffix you attach to a field name to say how to compare it. filter() keeps rows that match, exclude() drops them, and because every QuerySet method returns a new QuerySet you can chain them. Keyword arguments inside one call are always combined with AND .
Each lookup maps directly to a piece of the generated SQL WHERE clause:
Plain filter() arguments can only be AND-ed together, so for OR logic you wrap each condition in a Q object and combine them with | (OR), & (AND), and ~ (NOT). A Q is a reusable bundle of conditions you can build up and pass around.
You can also build a Q up dynamically and only run the query once it is complete:
An F expression refers to a field's current value in the database , so the work happens in SQL rather than Python. That unlocks two things: field-to-field comparisons (compare one column to another) and atomic updates that are safe even when many requests run at once.
The reason chaining all of this is cheap is that a QuerySet is lazy . Building it does nothing; the database is only hit when you force evaluation:
Fill in the blank so the lazy query only "runs" when you force evaluation with the method that returns its rows.
Chaining .filter(a=1).filter(b=2) gives you AND, not OR, so you get far fewer rows than expected.
✅ Fix: use a Q object — filter(Q(a=1) | Q(b=2)) — for true OR logic.
You misspelled the lookup or used a single underscore, so Django reads views_gte as a field name.
✅ Fix: use a double underscore — views__gte — between the field name and the lookup.
❌ Incrementing with obj.views += 1 loses updates
Reading into Python then saving lets two concurrent requests overwrite each other's count.
✅ Fix: use an atomic update with an F expression — .update(views=F("views") + 1) .
Put it all together: select the posts that are featured OR popular, then bump each one's views the way an F expression would in a single atomic update.
Lesson complete — you can write expressive, efficient queries!
You can now reach for field lookups like __gte and __icontains , chain filter() and exclude() , express OR/AND/NOT with Q objects, compare and atomically update fields with F expressions, and explain why a QuerySet stays lazy until it is evaluated.
🚀 Up next: Aggregation & Annotation — summarise and compute over whole QuerySets with Count, Sum, and annotate().
Practice quiz
What is a field lookup in Django?
- A double-underscore suffix describing how to compare a field
- A type of database index
- A model method
- A URL pattern
Answer: A double-underscore suffix describing how to compare a field. Lookups like views__gte are double-underscore suffixes that map to SQL comparisons.
Which lookup performs a case-insensitive substring match?
- __contains
- __icontains
- __iexact
- __startswith
Answer: __icontains. __icontains is a case-insensitive substring match (SQL LIKE, case-insensitive).
How are multiple keyword arguments inside a single filter() call combined?
- With OR
- Randomly
- With AND
- They overwrite each other
Answer: With AND. Keyword arguments in one filter() call are always combined with AND.
Which object lets you express OR logic in a query?
- An F expression
- A Manager
- A lookup
- A Q object
Answer: A Q object. Q objects combined with | express OR logic, which plain kwargs cannot.
Which operator combines two Q objects with OR?
- |
- &
- ~
- +
Answer: |. The | operator ORs two Q objects; & is AND and ~ is NOT.
What does an F expression refer to?
- A Python variable
- A field's current value in the database
- A foreign key target
- A form field
Answer: A field's current value in the database. F() references a model field's value in the database so the operation runs in SQL.
Why is .update(views=F('views') + 1) safer than reading and saving in Python?
- It is shorter to type
- It validates the field
- It increments atomically in the database, avoiding race conditions
- It skips the database
Answer: It increments atomically in the database, avoiding race conditions. The F-based update runs as one atomic SQL UPDATE, immune to concurrent overwrites.
What does ~Q(status='draft') express?
- status equals draft
- status is null
- status in a list
- NOT (status equals draft)
Answer: NOT (status equals draft). The ~ operator negates a Q object, producing a NOT condition.
When does a lazy QuerySet actually hit the database?
- When you iterate, slice, or call list() on it
- As soon as you call filter()
- When the module is imported
- Never
Answer: When you iterate, slice, or call list() on it. Building/chaining runs no SQL; evaluation happens on iteration, slicing, list(), etc.
Which lookup tests whether a field is NULL?
- __none
- __isnull
- __empty
- __null
Answer: __isnull. published_at__isnull=True matches rows where the column IS NULL.