Models & DatabasePro· 50 min read

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.

QuerySets are lazy — they run only when used
# 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.

Common field lookups
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
LookupMeans
__icontainscontains text, ignoring case
__gte / __ltegreater/less than or equal
__in=[...]value is in this list
__year / __datepart of a date
author__namereach 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):

Q objects give you OR and 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):

F refers to a column value in the database
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.

aggregate = one summary · annotate = 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:

The N+1 problem — slow
# 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+1

The 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:

  1. Use select_related for ForeignKey and OneToOne links — Django joins the tables in one query.
  2. Use prefetch_related for ManyToMany and reverse ForeignKey links — Django runs a small second query and stitches the results in Python.
  3. Either way you drop from N+1 queries to a small, fixed number (often 1 or 2).
selectrelated and prefetchrelated fix N+1
# 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?

Answer: This is the N+1 problem: 1 query for orders + 1 per order for the customer. select_related joins the customer (a ForeignKey) in a single query, removing the extra N trips.

✍️ Practice

  1. Write three queries using different field lookups (__icontains, __gte, __in).
  2. Annotate each author with their post count, then find the N+1 problem in a loop and fix it with select_related or prefetch_related.

🏠 Homework

  1. Install django-debug-toolbar, open a list page, and reduce its query count using selectrelated/prefetchrelated. Note the before/after numbers.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →