The ORM in Depth
Filter, look up, aggregate and — crucially — make queries fast with selectrelated and prefetchrelated to kill the N+1 problem.
What you will learn
- Filter with field lookups, Q and F objects
- Summarise data with annotate and aggregate
- Avoid the N+1 problem with selectrelated / prefetchrelated
A QuerySet is a lazy, chainable question
A QuerySet is Django’s representation of "a set of rows I want from the database." Two facts make it powerful. First, it is lazy: writing the query does not touch the database — it only runs when you actually use the results (loop over them, print them, call list()). Second, it is chainable: each method returns another QuerySet, so you can keep refining.
# none of these hit the database yet — they just describe the query
recent = Post.objects.filter(published=True).order_by("-created")[:5]
# THIS is when the SQL actually runs:
for p in recent:
print(p.title)Note: Output: the database is queried exactly once, at the for loop. Up to that point recent is just a stored plan. This laziness is why you can build a query in pieces without paying for each step.
Field lookups — filtering with conditions
A field lookup is a condition you attach to a field name with a double underscore: field__lookup=value. It is how the ORM expresses "greater than", "contains", "in this list", and so on, without writing SQL.
Post.objects.filter(title__icontains="django") # title contains "django" (case-insensitive)
Post.objects.filter(views__gte=100) # views >= 100
Post.objects.filter(created__year=2026) # created in year 2026
Post.objects.filter(author__name="Asha") # follow the ForeignKey: author's name
Post.objects.exclude(published=False) # everything that is NOT a draft| Lookup | Means |
|---|---|
__icontains | contains text, ignoring case |
__gte / __lte | greater/less than or equal |
__in=[...] | value is in this list |
__year / __date | part of a date |
author__name | reach across a relationship |
Q objects — OR / NOT logic
Stacking filters means AND (all must be true). When you need OR, you use a Q object — a wrapped condition you can combine with | (or) and & (and), and flip with ~~~ (not):
from django.db.models import Q
# posts that are featured OR have 1000+ views
Post.objects.filter(Q(featured=True) | Q(views__gte=1000))
# published AND NOT by Asha
Post.objects.filter(Q(published=True) & ~Q(author__name="Asha"))F expressions — compare/update using other columns
An F expression refers to a column’s own value inside the database, so you can compare two columns or update a field based on its current value — all in one safe database operation (no race conditions):
from django.db.models import F
# posts where likes are more than comments (column vs column)
Post.objects.filter(likes__gt=F("comments"))
# add 1 to views safely, in the database, with no read-then-write gap
Post.objects.filter(id=1).update(views=F("views") + 1)annotate & aggregate — summarising data
aggregate boils a whole QuerySet down to a single summary value (a total, an average). annotate attaches a computed value to each row (for example, how many posts each author has). The difference is one number for the set vs one number per row.
from django.db.models import Count, Avg
# aggregate: ONE number for the whole set
Post.objects.aggregate(Avg("views"))
# -> {'views__avg': 342.5}
# annotate: a number attached to EACH author
authors = Author.objects.annotate(num_posts=Count("posts"))
for a in authors:
print(a.name, a.num_posts)Note: Output:
{'views__avg': 342.5}
Asha 12
Ravi 7
aggregate returned a single dict (the average across all posts). annotate gave every author an extra num_posts value computed from the related posts.
The N+1 problem — the #1 performance bug
Here is the trap that catches almost every new Django developer. The N+1 problem is when your code runs 1 query to fetch a list, then N more queries — one per row — to fetch each row’s related data. With 100 posts that is 101 database trips instead of 2, and the page crawls.
Watch it happen. This innocent-looking loop hides a query on every iteration:
# BAD: 1 query for posts, then 1 MORE query per post for its author
posts = Post.objects.all() # 1 query
for post in posts:
print(post.author.name) # +1 query EACH time -> N+1The fix is to tell Django to fetch the related data up front, in the same trip. Two methods do this, one for each relationship shape:
- Use
select_relatedforForeignKeyandOneToOnelinks — Django joins the tables in one query. - Use
prefetch_relatedforManyToManyand reverseForeignKeylinks — Django runs a small second query and stitches the results in Python. - Either way you drop from N+1 queries to a small, fixed number (often 1 or 2).
# GOOD: join the author in the SAME query — total of 1 query
posts = Post.objects.select_related("author")
for post in posts:
print(post.author.name) # no extra queries
# GOOD for many-to-many / reverse FK — 2 queries total, not N+1
authors = Author.objects.prefetch_related("posts")
for a in authors:
print(a.name, list(a.posts.all()))Note: Output (same data as before): the page now makes 1–2 database queries instead of 101. The visible result is identical — only far faster. This single skill is one of the most asked-about topics in backend interviews.
Tip: Rule of thumb: looping over rows and touching a related object inside the loop? Reach for select_related (ForeignKey/OneToOne) or prefetch_related (ManyToMany/reverse). Install django-debug-toolbar to literally count the queries a page makes.
Q. You loop over 200 orders and print each order’s customer name, and the page is painfully slow. What is the fix?
✍️ Practice
- Write three queries using different field lookups (
__icontains,__gte,__in). - Annotate each author with their post count, then find the N+1 problem in a loop and fix it with
select_relatedorprefetch_related.
🏠 Homework
- Install django-debug-toolbar, open a list page, and reduce its query count using selectrelated/prefetchrelated. Note the before/after numbers.