QuerySet Deep Dive: Q, F & Lookups
Reviewed & published by Brayan K
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.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, 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.
What You'll Learn in This Lesson
- Field lookups: __gte, __lte, __icontains, __in, __isnull
- Chaining filter() and exclude() to narrow results
- Q objects for OR, AND, and NOT logic
- F expressions for field-to-field comparisons
- Atomic updates with .update(views=F('views') + 1)
- Why QuerySets are lazy and when they evaluate
1 Field Lookups & Chaining filter() / exclude()
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.
# Real Django ORM lookups
from blog.models import Post
# >= and <= comparisons
Post.objects.filter(views__gte=100) # views >= 100
Post.objects.filter(views__lte=500) # views <= 500
# case-insensitive substring match
Post.objects.filter(title__icontains="django")
# membership and NULL tests
Post.objects.filter(status__in=["published", "featured"])
Post.objects.filter(published_at__isnull=True) # never published
# chaining: keep popular posts, then drop drafts/archived
Post.objects.filter(views__gte=100).exclude(status__in=["draft", "archived"])Each lookup maps directly to a piece of the generated SQL WHERE clause:
# Post.objects.filter(views__gte=100).exclude(status__in=["draft", "archived"])
# becomes roughly:
SELECT * FROM blog_post
WHERE views >= 100
AND NOT (status IN ('draft', 'archived'));# Field lookups + chaining filter()/exclude() turn into row-by-row tests.
# Here we filter a list of dicts with the SAME semantics Django uses for
# __gte, __lte, __icontains and __in, then chain filter then exclude.
posts = [
{"title": "Django Tips", "views": 250, "status": "published"},
{"title": "Vite Basics", "views": 80, "status": "draft"},
{"title": "Deep Django", "views": 500, "status": "published"},
{"title": "React Notes", "views": 120, "status": "archived"},
]
def matches(row, lookups):
for key, value in lookups.items():
field, _, op = key.partition("__")
cell = row[field]
if op == "gte" and not (cell >= value):
return False
if op == "lte" and not (cell <= value):
return False
if op == "icontains" and value.lower() not in str(cell).lower():
return False
if op == "in" and cell not in value:
return False
return True
def filter_rows(rows, **lookups):
return [r for r in rows if matches(r, lookups)]
def exclude_rows(rows, **lookups):
return [r for r in rows if not matches(r, lookups)]
# Post.objects.filter(views__gte=100).exclude(status__in=["draft", "archived"])
result = exclude_rows(
filter_rows(posts, views__gte=100),
status__in=["draft", "archived"],
)
for row in result:
print(row["title"], row["views"])
# EXPECTED:
# Django Tips 250
# Deep Django 500
# ✅ Expected output:
# Django Tips 250
# Deep Django 5002 Q Objects for OR, AND & NOT
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.
# Real Django Q objects
from django.db.models import Q
from blog.models import Post
# OR: popular OR featured
Post.objects.filter(Q(views__gte=100) | Q(featured=True))
# AND (the same as comma-separated kwargs, but explicit)
Post.objects.filter(Q(views__gte=100) & Q(status="published"))
# NOT: everything that is NOT a draft
Post.objects.filter(~Q(status="draft"))
# Grouped logic: (popular OR featured) AND published
Post.objects.filter(
(Q(views__gte=100) | Q(featured=True)) & Q(status="published")
)You can also build a Q up dynamically and only run the query once it is complete:
# Build a condition based on user input, then apply it
condition = Q(status="published")
if request.GET.get("popular"):
condition &= Q(views__gte=100)
if request.GET.get("featured"):
condition |= Q(featured=True)
results = Post.objects.filter(condition)# A Q object is a reusable bundle of conditions you can combine with
# | (OR), & (AND) and ~ (NOT). We mimic that by wrapping a test function
# and overloading those same operators over a list of dicts.
posts = [
{"title": "Django Tips", "views": 250, "featured": False},
{"title": "Vite Basics", "views": 80, "featured": True},
{"title": "Deep Django", "views": 500, "featured": False},
{"title": "React Notes", "views": 50, "featured": False},
]
class Q:
def __init__(self, test):
self.test = test
def __or__(self, other):
return Q(lambda r: self.test(r) or other.test(r))
def __and__(self, other):
return Q(lambda r: self.test(r) and other.test(r))
def __invert__(self):
return Q(lambda r: not self.test(r))
# Post.objects.filter(Q(views__gte=100) | Q(featured=True))
condition = Q(lambda r: r["views"] >= 100) | Q(lambda r: r["featured"])
for row in posts:
if condition.test(row):
print(row["title"])
# EXPECTED:
# Django Tips
# Vite Basics
# Deep Django
# ✅ Expected output:
# Django Tips
# Vite Basics
# Deep Django3 F Expressions & QuerySet Laziness
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.
# Real Django F expressions
from django.db.models import F
from shop.models import Product
# Field-to-field comparison: price greater than cost
Product.objects.filter(price__gt=F("cost"))
# Atomic increment — runs as one UPDATE, no race condition
Product.objects.update(views=F("views") + 1)
# Arithmetic across fields in an update
Product.objects.update(price=F("cost") * 2)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:
# Nothing has touched the database yet — qs is just a query description
qs = Post.objects.filter(views__gte=100).exclude(status="draft")
# Evaluation happens when you:
for post in qs: # 1. iterate
print(post.title)
list(qs) # 2. call list()
qs[0] # 3. index / slice
bool(qs) # 4. test truthiness
# Each of those sends exactly ONE SQL query.- F("field") — refers to a column's value in the database, evaluated in SQL.
- .update(views=F("views") + 1) — an atomic increment, immune to race conditions.
- filter(a=F("b")) — compare one field against another field.
- A QuerySet evaluates on iteration, list(), slicing, or a truthiness test — never before.
# F() references a column's CURRENT value in the database, so the update
# happens atomically inside one SQL statement. Compare these two rows:
# one uses field-to-field comparison, the other an atomic increment.
products = [
{"name": "Keyboard", "price": 40, "cost": 30, "views": 10},
{"name": "Monitor", "price": 200, "cost": 220, "views": 5},
{"name": "Mouse", "price": 25, "cost": 25, "views": 8},
]
# Product.objects.filter(price__gt=F("cost")) -> price greater than cost
print("Profitable (price > cost):")
for p in products:
if p["price"] > p["cost"]:
print(" ", p["name"])
# Product.objects.update(views=F("views") + 1) -> atomic increment
for p in products:
p["views"] = p["views"] + 1
print("Views after F increment:")
for p in products:
print(" ", p["name"], p["views"])
# EXPECTED:
# Profitable (price > cost):
# Keyboard
# Views after F increment:
# Keyboard 11
# Monitor 6
# Mouse 9
# ✅ Expected output:
# Profitable (price > cost):
# Keyboard
# Views after F increment:
# Keyboard 11
# Monitor 6
# Mouse 9🎯 Your Turn: Force a Lazy QuerySet to Evaluate
Fill in the blank so the lazy query only "runs" when you force evaluation with the method that returns its rows.
# 🎯 YOUR TURN: a QuerySet is LAZY — it does not hit the database until
# you iterate, slice, or call list() on it. Replace ___ so the query
# only "runs" when we force evaluation with list().
queries_run = []
class LazyQuerySet:
def __init__(self, rows):
self.rows = rows
def filter(self, predicate):
# filtering just records intent, it does NOT evaluate yet
return LazyQuerySet([r for r in self.rows if predicate(r)])
def evaluate(self):
queries_run.append("SELECT ...") # the DB hit happens here
return list(self.rows)
qs = LazyQuerySet([1, 2, 3, 4, 5]).filter(lambda n: n > 2)
print("Queries so far:", len(queries_run)) # still lazy -> 0
results = qs.___() # force evaluation
print("Results:", results)
print("Queries after list():", len(queries_run))
# EXPECTED OUTPUT:
# Queries so far: 0
# Results: [3, 4, 5]
# Queries after list(): 1Common Errors (And How to Fix Them)
❌ Using OR by passing two filter() calls
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.
❌ "Related Field got invalid lookup: gte"
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).
📋 Quick Reference
| Tool | What It Does |
|---|---|
| views__gte=100 | Keep rows where views >= 100 (also __lte, __gt, __lt) |
| title__icontains="x" | Case-insensitive substring match on a field |
| status__in=[...] | Match any value in the list; __isnull tests for NULL |
| Q(a=1) | Q(b=2) | OR logic; use & for AND and ~ for NOT |
| F("views") + 1 | Reference a field's DB value for atomic updates |
🏆 Mini Challenge: Combine Lookups, Q & F
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.
# 🏆 Mini Challenge: combine field lookups, an OR (Q) and an F-style
# atomic update. Find posts that are featured OR have 100+ views, then
# bump every matching post's views by 10 the way F("views") + 10 would.
posts = [
{"title": "Django Tips", "views": 250, "featured": False},
{"title": "Vite Basics", "views": 80, "featured": True},
{"title": "Deep Django", "views": 500, "featured": False},
{"title": "React Notes", "views": 50, "featured": False},
]
def featured_or_popular(row):
return row["featured"] or row["views"] >= 100
selected = [p for p in posts if featured_or_popular(p)]
# Atomic F-style increment on the selected rows
for p in selected:
p["views"] = p["views"] + 10
print("Updated", len(selected), "posts")
for p in selected:
print(p["title"], "->", p["views"])
# EXPECTED:
# Updated 3 posts
# Django Tips -> 260
# Vite Basics -> 90
# Deep Django -> 510❓ Frequently Asked Questions
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.
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.
Continue this course
- Previous: Model Methods, __str__ & Meta
- Next: Aggregation & Annotation — Summarise and group data with Count, Sum, Avg, and annotate()
- Quick reference: Django cheat sheet
Frequently asked questions
What is a field lookup in Django?
A field lookup is the double-underscore suffix you add to a field name inside filter(), exclude(), or get() to describe how to compare it — for example views__gte=100 means 'views greater than or equal to 100'. Django translates the lookup into the matching SQL WHERE clause, so __gte becomes >=, __icontains becomes a case-insensitive LIKE, __in becomes IN (...), and __isnull becomes IS NULL. Lookups are how you express conditions without writing raw SQL.
When should I use a Q object instead of plain filter() arguments?
Reach for a Q object whenever you need OR logic or grouped/negated conditions, because the keyword arguments to filter() are always combined with AND. Q lets you write Q(a=1) | Q(b=2) for OR, ~Q(c=3) for NOT, and nest them with parentheses for complex boolean expressions. For straightforward AND conditions, the simpler keyword form is fine and more readable.
What does an F expression actually do?
An F expression refers to the value of a model field directly in the database, so the operation runs in SQL instead of pulling the row into Python. That makes F('price') > F('cost') a field-to-field comparison and .update(views=F('views') + 1) an atomic increment that avoids race conditions between concurrent requests. Because the work happens in the database, F is both faster and safe under concurrency.
Why is a QuerySet called lazy?
A QuerySet is lazy because building and chaining it — calling filter(), exclude(), order_by() and so on — does not touch the database. Django waits until you actually need the data, which happens when you iterate over the QuerySet, slice it, call list() on it, or otherwise force evaluation. This lets you compose a complex query in several steps while still sending just one efficient SQL statement.