The order list page is slow
New to Django, the request lifecycle or joins? Open a quick recap. Each takes about two minutes.
Quick recapDjango: models, QuerySets and templates· 2 min
A model is a Python class for a table: Order for orders. A ForeignKey links rows: each order has a
customer. Order.objects.filter(...) gives a QuerySet, a query that hasn’t run yet. A view passes data to a
template, which loops over it with {% for %} and shows fields with {{ order.total }}.
Quick recapThe request lifecycle· 2 min
A request goes through middleware (sessions, security checks), then the view (your function), which usually renders a template into HTML. The response goes back out through the middleware. Any step can talk to the database.
Quick recapJoins· 2 min
A JOIN matches rows of two tables on a column, in one query. orders JOIN customers ON orders.customer_id = customers.customer_id gives each order with its customer’s name. One query instead of one per order.
See the problem
Section titled “See the problem”Your shop has a staff page that lists every order. At launch, with a dozen orders, it opened instantly. Six months later, with 500 orders, it takes 4 seconds. The database dashboard shows about 1,000 queries every time someone opens it. Nobody changed the code.
Here is the view, and the row of the template:
from django.shortcuts import render
from ..models import Order
def order_list_okay(request): # 1 query for the orders. Then the template asks for each # order's customer and item count: 2 more queries per row. orders = Order.objects.order_by("-order_date", "-id") context = {"orders": orders} return render(request, "shop/order_list_okay.html", context)<tr><td>{{ order.id }}</td> … <td>{{ order.customer.name }}</td><td>{{ order.items.count }}</td> …</tr>It looks like one query. It isn’t. “This page is slow. Why, and how do you fix it?” is one of the most common Django interview questions.
Define the task
Section titled “Define the task”Input: the shop’s tables, the same ones as in the SQL track: orders, customers and order_items.
Output: a page with one row per order, newest first: the order number, date, customer name, number of items, status and total. For the 6 newest orders:
| Order | Date | Customer | Items | Status | Total |
|---|---|---|---|---|---|
| 114 | 2026-10-20 | Hugo Silva | 2 | placed | 142.99 |
| 113 | 2026-10-18 | Farid Haddad | 3 | placed | 49.98 |
| 112 | 2026-10-16 | Grace Kim | 1 | delivered | 9.99 |
| 111 | 2026-10-15 | Elif Demir | 2 | placed | 54.00 |
| 110 | 2026-10-14 | Ben Okafor | 1 | placed | 59.00 |
| 109 | 2026-10-14 | Ben Okafor | 2 | placed | 13.19 |
The goal: the number of queries must not grow with the number of orders, and the page must stay fast at 50,000 orders.
Pause and think
Section titled “Pause and think”Hint 1 of 3
A QuerySet sends nothing when you build it. Which line first reads its rows?
Hint 2 of 3
The order row has customer_id, but not the customer’s name. What must order.customer.name do?
Hint 3 of 3
order.items.count runs SELECT COUNT(*) each time it’s called. How many times is it called?
Understand the idea
Section titled “Understand the idea”Follow one request for the page. The counter shows every query sent so far, grouped by shape, so 500 lookups fit on one line. Then try your own number of orders.
Okaythe plain QuerySet · GET /orders/okay/
- Middleware
- View
- Template
- Response
RunningSessionMiddleware, CsrfViewMiddleware, …
0queries
No queries sent yet.
Step 1 of 11. GET /orders/okay/ arrives. Middleware runs first: sessions, security checks. No queries yet.
Climb the ladder
Section titled “Climb the ladder”Three versions of the view. Each one shows the idea, the code, its cost and where it breaks. Here n is the number of orders. The full project, with tests, is in examples/django/ (run it with ./run.sh examples django).
The plain QuerySet
The idea: Hand the template every order, and let it follow order.customer and order.items when it needs them.
The template:
{% extends "shop/base.html" %}{% block rows %} {% for order in orders %} {# order.customer and order.items.count each run a query, for every row #} <tr><td>{{ order.id }}</td><td>{{ order.order_date|date:"Y-m-d" }}</td><td>{{ order.customer.name }}</td><td>{{ order.items.count }}</td><td>{{ order.status }}</td><td>{{ order.total }}</td></tr> {% endfor %}{% endblock %}For the 6 orders: 1 query for the orders, then 2 per row, so 1 + 2 × 6 = 13. Ben Okafor is fetched twice, once for order 110 and again for 109: each order keeps its own copy.
On our laptop, with 514 orders and the database on the same machine, the page sent 1,029 queries and took 0.38 seconds. With the database on another server, every query also waits for the network.
from django.shortcuts import render
from ..models import Order
def order_list_okay(request): # 1 query for the orders. Then the template asks for each # order's customer and item count: 2 more queries per row. orders = Order.objects.order_by("-order_date", "-id") context = {"orders": orders} return render(request, "shop/order_list_okay.html", context)- Queries
1 + 2n- Rows loaded
n orders, one customer and one count at a time- Why
- Each row fetches its own customer and counts its own items: two round trips to the database per order.
Where it breaks: every new order adds 2 queries, and every order is on the page. The page gets slower each day the shop is open.
Count in the database, a page at a time
The idea: Let the database count each order's items, read only the columns the page shows, and show 25 orders a page.
The waste in Better: it moves every item row to Python only to count it, and it draws every order. The database can count, and nobody reads 500 orders at once.
The template shows {{ order.item_count }}, the number the database worked out. With 514 orders, the page took 0.007 seconds. At 50,000 orders, it still sends 2 queries and loads 25 rows.
Highlighted lines are new in this rung.
from django.core.paginator import Paginatorfrom django.db.models import Countfrom django.shortcuts import render
from ..models import Order
PAGE_SIZE = 25
def order_list(request): # Count the items in the database, read only the columns # the page shows, and send 25 orders at a time. orders = ( Order.objects.select_related("customer") .only("order_date", "status", "total", "customer__name") .annotate(item_count=Count("items")) .order_by("-order_date", "-id") ) paginator = Paginator(orders, PAGE_SIZE) page = paginator.get_page(request.GET.get("page")) context = {"page": page} return render(request, "shop/order_list.html", context)- Queries
2- Rows loaded
25 orders, no itemswas n orders and all their items- Why
- annotate(Count) becomes COUNT(...) with GROUP BY in SQL, only() leaves out columns like the customer's email, and the Paginator adds LIMIT 25. One query counts the orders for the page links; one gets the page.
What changed from the previous rung
from django.core.paginator import Paginatorfrom django.db.models import Countfrom django.shortcuts import render
from ..models import Order
PAGE_SIZE = 25
def order_list_better(request): # select_related: JOIN customers into the orders query. # prefetch_related: 1 more query for all the items, # matched to their orders in Python.
def order_list(request): # Count the items in the database, read only the columns # the page shows, and send 25 orders at a time. orders = ( Order.objects.select_related("customer") .prefetch_related("items") .only("order_date", "status", "total", "customer__name") .annotate(item_count=Count("items")) .order_by("-order_date", "-id") ) context = {"orders": orders} return render(request, "shop/order_list_better.html", context) paginator = Paginator(orders, PAGE_SIZE) page = paginator.get_page(request.GET.get("page")) context = {"page": page} return render(request, "shop/order_list.html", context)When Best isn’t best: an export of every order shouldn’t page through them. Stream it with .iterator() or build it in a background job. And if 2 queries are still slow, read the query plan and add an index before you reach for a cache.
Prove it with a test
Section titled “Prove it with a test”A fix nobody checks comes back. Six months from now, someone adds {{ order.customer.city }} to the template, and the page is slow again. This test fails the moment the query count grows with the number of orders. It uses pytest-django; Django’s own TestCase has assertNumQueries, which does the same.
# From examples/django/shop/tests/test_order_list.py. make_orders and rows are small helpers there.def test_best_runs_two_queries_and_shows_one_page(client, django_assert_num_queries): make_orders(60) with django_assert_num_queries(2): # COUNT for the page links, then the page itself response = client.get("/orders/") assert len(rows(response.content.decode())) == 25Write it yourself
Section titled “Write it yourself”prefetch_related does two things. It sends one query for the items of every order on the page. Then it matches each item to its order, in Python. Write the matching step.
If you loop over every item for every order, that’s n × m steps: fine for 25 orders, far too slow for 10,000. Go through the items once.
Match items to their orders
Ctrl + Enter runs- Not run yet:
attach_items([114, 113], [[113, 'Mug'], [114, 'Tea'], [114, 'Pens']])→[[114, ['Tea', 'Pens']], [113, ['Mug']]] - Not run yet:
attach_items([114, 112], [[114, 'Tea']])→[[114, ['Tea']], [112, []]] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Rung | Queries | Rows loaded | 514 orders, our laptop | Good | Not so good | Use it when |
|---|---|---|---|---|---|---|
| Okay | 1 + 2n | n orders, one by one | 1,029 queries, 0.38 s | Simplest code | Slower with every order | A page that never has many rows |
| Better | 2 | n orders, all their items | 2 queries, 0.08 s | Fixes the N+1 with two words | Loads items only to count them; no pages | You really show the items |
| Best | 2 | 25 orders, counts only | 2 queries, 0.007 s | Same cost at any size, tested | More code: annotate, only, a paginator | Almost always, for list pages |
How much work is that? Each bar is one rung at the same number of orders, counted in queries. Pick a size, or run the race.
OkayThe plain QuerySet ·
1 + 2n≈ 1,000 queries · finishedBetterselect_related and prefetch_related ·
2≈ 2 queries · finishedBestCount in the database, a page at a time ·
2≈ 2 queries · finished
At n = 500, Okay does about 500 times more work than Better.
Every bar does the same number of steps per second, so the race shows which rung finishes first. Turn on Log scale to compare the short bars.
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- select_related is for one row; prefetch_related is for many. Use
select_relatedto follow aForeignKeyorOneToOneField(each order has one customer). Useprefetch_relatedfor the other direction or aManyToManyField(each order has many items).select_related("items")is an error. - only() can bring the N+1 back. If the template reads a column you left out, Django loads it with one query per row. Keep
only()in step with the template, or leave it out. - N+1 hides outside templates too: in model properties,
__str__, serializers and the admin’slist_display. The admin haslist_select_relatedfor this. - The page count can be the slow part. The Paginator counts the whole QuerySet, joins and all. On a very big table, give it a cheaper count, or show “Older” and “Newer” links without a total.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- How many orders are there now, and how many will there be in a year?
- Does the page need every order, or would pages of 25 do?
- Which fields does it show, and from which tables?
- Is the time spent in the database, or in Python and the template?
A 60-second answer
“With 500 orders the page sends about 1,000 queries, so this is an N+1. The view’s QuerySet is lazy and runs once, but the template reads
order.customer.nameandorder.items.countfor every row, which is 1 + 2n queries. First I’d confirm it with the query count per request, from the Django Debug Toolbar or the logs. The quick fix isselect_related('customer')for the foreign key andprefetch_related('items')for the items: 2 queries at any size. But we only need the number of items, so I’d let the database count them withannotate(Count('items')). I’d read only the shown columns withonly(), and show 25 orders per page. Last, a test withassertNumQueries(2), so the N+1 can’t come back unnoticed.”
Likely follow-ups
- select_related or prefetch_related?
select_relatedjoins in one query and works for a single related row.prefetch_relatedruns one more query per relation and works for many related rows. - How do you find N+1 queries in production? Count queries per request in the logs or your APM tool, and look for the same query shape repeated with different ids. Locally, the Django Debug Toolbar shows them.
- You only need some of the related rows? Use a
Prefetchobject with its ownqueryset, andto_attrto keep them in a plain list. - Would you cache the page? Only after fixing the queries. A cache hides the N+1 until the next cache miss, and it can show stale orders.
Red flags interviewers notice
- Adding a cache or a bigger database before counting queries.
- Using
select_relatedon a many relation, or not knowing why it fails. - Fixing the N+1 with no test, so it quietly comes back.
- Loading every row to count them in Python.
Practice
Section titled “Practice”Four more pages to fix, from easy to hard. Each has a hint or two, then Okay, Better and Best versions. Try each one before you open the answers.
1. Reviews with their authors (easy)
Section titled “1. Reviews with their authors (easy)”A product page lists its reviews. The view is Review.objects.filter(product=product), and the template shows {{ review.author.username }} for each one. A product with 200 reviews sends 201 queries. Fix it.
Hint 1 of 2
Each review has one author: a ForeignKey.
Hint 2 of 2
Which of the two tools follows a ForeignKey with a JOIN?
Show the solutions
One query per author
The idea: The plain QuerySet: the template fetches each review's author on its own.
reviews = Review.objects.filter(product=product)- Queries
1 + n- Rows loaded
n reviews, n authors one by one- Why
- review.author isn't loaded, so every row asks for it.
Join the authors in
The idea: select_related follows the ForeignKey with a JOIN, so each review arrives with its author.
reviews = Review.objects.filter(product=product).select_related("author")- Queries
1was 1 + n- Rows loaded
n reviews with every author column- Why
- One query with a JOIN on users.
Join, trim and page
The idea: Read only the columns shown and show 20 reviews at a time, newest first.
reviews = ( Review.objects.filter(product=product) .select_related("author") .only("rating", "text", "created_at", "author__username") .order_by("-created_at"))page = Paginator(reviews, 20).get_page(request.GET.get("page"))- Queries
2- Rows loaded
20 reviews, a few columns- Why
- A COUNT for the page links, then one page. The users table's other columns, like the password hash, stay in the database.
2. Blog posts with their tags (medium)
Section titled “2. Blog posts with their tags (medium)”A blog’s home page shows the 10 newest posts, each with its tags. Post.tags is a ManyToManyField, and the template loops over {% for tag in post.tags.all %}. That’s 11 queries. A teammate tries select_related("tags"), and Django raises an error. Why, and what works?
Hint 1 of 2
A post has many tags, and a tag has many posts.
Hint 2 of 2
A JOIN would repeat each post once per tag. Which tool runs a second query instead?
Show the solutions
One tags query per post
The idea: The plain QuerySet: each post's tags are loaded when the template loops over them.
posts = Post.objects.order_by("-published_at")[:10]- Queries
1 + n- Rows loaded
n posts, their tags one post at a time- Why
- post.tags.all runs a query for each post.
Prefetch the tags
The idea: prefetch_related loads the tags of all 10 posts in one more query and matches them in Python.
posts = Post.objects.order_by("-published_at").prefetch_related("tags")[:10]- Queries
2was 1 + n- Rows loaded
n posts and their tags- Why
- select_related only follows single rows (ForeignKey, OneToOne), so it can't load a list of tags. prefetch_related can.
Prefetch only what's shown
The idea: Load just the tag names, sorted, and test the count.
posts = ( Post.objects.order_by("-published_at") .only("title", "slug", "published_at") .prefetch_related(Prefetch("tags", queryset=Tag.objects.only("name").order_by("name"))))[:10]
# In the test:with django_assert_num_queries(2): client.get("/")- Queries
2- Rows loaded
10 posts, tag names only- Why
- The Prefetch object gives the second query its own columns and order. The test fails if a third query appears.
3. Customers with only their shipped orders (medium)
Section titled “3. Customers with only their shipped orders (medium)”A report lists customers, each with their shipped orders. The loop calls customer.orders.filter(status="shipped"). The team added prefetch_related("orders"), but the page still sends one query per customer. Why, and what’s the fix?
Hint 1 of 2
What does .filter() on a prefetched relation do?
Hint 2 of 2
Can you tell prefetch_related which orders to load?
Show the solutions
Filter in the loop
The idea: Prefetch all orders, then filter each customer's orders in the template.
customers = Customer.objects.prefetch_related("orders")# in the loop:customer.orders.filter(status="shipped")- Queries
2 + n- Rows loaded
every order, used for nothing- Why
- .filter() builds a new QuerySet, which ignores the prefetched rows and asks the database again.
Filter in Python
The idea: Use the prefetched orders and keep the shipped ones in Python.
customers = Customer.objects.prefetch_related("orders")# in the loop:[o for o in customer.orders.all() if o.status == "shipped"]- Queries
2was 2 + n- Rows loaded
every order of every customer- Why
- No extra queries, but it loads cancelled and delivered orders only to throw them away.
A Prefetch with its own queryset
The idea: Tell prefetch_related to load only shipped orders, into a list on each customer.
customers = Customer.objects.prefetch_related( Prefetch( "orders", queryset=Order.objects.filter(status="shipped").order_by("-order_date"), to_attr="shipped_orders", ))# in the loop:customer.shipped_orders- Queries
2- Rows loaded
shipped orders only- Why
- The second query has WHERE status = 'shipped'. to_attr puts the result in a plain list, so nobody calls .filter() on it by mistake.
4. An API that counts in a property (hard)
Section titled “4. An API that counts in a property (hard)”GET /api/orders/ returns JSON for 50 orders, each with item_count. The model has a property:
@propertydef item_count(self): return self.items.count()The API sends 51 queries. The view looks clean: Order.objects.order_by("-id")[:50]. Find the N+1, fix it, and keep the property working for code that uses a single order.
Hint 1 of 3
Where does the query hide? Look at what runs for each order while the JSON is built.
Hint 2 of 3
Can the QuerySet bring the count with it?
Hint 3 of 3
An annotation can have a different name from the property, and the property can use it when it’s there.
Show the solutions
The property counts each order
The idea: The JSON code reads order.item_count, so every order runs its own COUNT query.
orders = Order.objects.order_by("-id")[:50]data = [{"id": o.id, "item_count": o.item_count} for o in orders]- Queries
1 + n- Rows loaded
n orders, a count per order- Why
- A property that queries looks like a plain field, so the N+1 is easy to miss.
Annotate, and read the annotation
The idea: Have the database count the items in the same query, and use that number in the view.
orders = Order.objects.annotate(n_items=Count("items")).order_by("-id")[:50]data = [{"id": o.id, "item_count": o.n_items} for o in orders]- Queries
1was 1 + n- Rows loaded
n orders with their counts- Why
- COUNT with GROUP BY in one query. But any other code that reads the property still runs a query each time.
The property uses the annotation when it's there
The idea: One property for both: the annotated count when the QuerySet brought it, a query otherwise. Pin the API with a test.
class Order(models.Model): @property def item_count(self): if hasattr(self, "n_items"): # set by .annotate(n_items=Count("items")) return self.n_items return self.items.count()
# The view:orders = Order.objects.annotate(n_items=Count("items")).order_by("-id")[:50]
# The test:with django_assert_num_queries(1): client.get("/api/orders/")- Queries
1- Rows loaded
n orders with their counts- Why
- List pages annotate and send one query. Code that loads a single order still works. The test catches anyone who drops the annotation.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- N+1: 1 query for a list, then 1 more per row. Spot it as the same query shape repeated with different ids.
- QuerySets are lazy: they run when something reads the rows (a loop,
len(),list()). select_related("customer"): a JOIN, for aForeignKeyorOneToOneField.prefetch_related("items"): one more query withIN (…), for reverse foreign keys andManyToManyField.Prefetch("orders", queryset=…, to_attr=…): prefetch only some rows.- Count in the database:
annotate(item_count=Count("items")). Not.count()in a loop. only(...)reads fewer columns. Touching a left-out one costs a query per row.- Paginate list pages. Pin the query count with
assertNumQueriesordjango_assert_num_queries.
Go deeper
Section titled “Go deeper”- Database access optimization, from the Django docs: the full checklist.
- select_related and prefetch_related, with the
Prefetchobject. - assertNumQueries, the test that keeps a page’s query count fixed.
- The example project:
examples/django/in this site’s repository, with all three views, the tests and a query counter in the terminal.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started