Skip to content

The order list page is slow

  • Frameworks · Core
  • 25 min read, about 1 h 20 min with practice
  • Problem
  • Difficulty: medium
  • Asked often: high
  • Covers Django 5.2, Python 3.11+
  • Reviewed 2026-11

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.

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:

shop/views/okay.py
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.

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:

OrderDateCustomerItemsStatusTotal
1142026-10-20Hugo Silva2placed142.99
1132026-10-18Farid Haddad3placed49.98
1122026-10-16Grace Kim1delivered9.99
1112026-10-15Elif Demir2placed54.00
1102026-10-14Ben Okafor1placed59.00
1092026-10-14Ben Okafor2placed13.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.

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?

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.

One request for the orders page

Okaythe plain QuerySet · GET /orders/okay/

  1. Middleware
  2. View
  3. Template
  4. 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.

Try your own input

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).

Okay

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:

shop/templates/shop/order_list_okay.html
{% 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.

Best

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 Paginator
from django.db.models import Count
from 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 Paginator
from django.db.models import Count
from 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.

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())) == 25

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: attach_items([114, 113], [[113, 'Mug'], [114, 'Tea'], [114, 'Pens']]) → [[114, ['Tea', 'Pens']], [113, ['Mug']]]
  2. Not run yet: attach_items([114, 112], [[114, 'Tea']]) → [[114, ['Tea']], [112, []]]
  3. Not run yet: Extra test
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
RungQueriesRows loaded514 orders, our laptopGoodNot so goodUse it when
Okay1 + 2nn orders, one by one1,029 queries, 0.38 sSimplest codeSlower with every orderA page that never has many rows
Better2n orders, all their items2 queries, 0.08 sFixes the N+1 with two wordsLoads items only to count them; no pagesYou really show the items
Best225 orders, counts only2 queries, 0.007 sSame cost at any size, testedMore code: annotate, only, a paginatorAlmost 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.

Order page: queries at n orders
  1. OkayThe plain QuerySet · 1 + 2n≈ 1,000 queries

  2. Betterselect_related and prefetch_related · 2≈ 2 queries

  3. BestCount in the database, a page at a time · 2≈ 2 queries

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.

  • select_related is for one row; prefetch_related is for many. Use select_related to follow a ForeignKey or OneToOneField (each order has one customer). Use prefetch_related for the other direction or a ManyToManyField (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’s list_display. The admin has list_select_related for 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.

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.name and order.items.count for 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 is select_related('customer') for the foreign key and prefetch_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 with annotate(Count('items')). I’d read only the shown columns with only(), and show 25 orders per page. Last, a test with assertNumQueries(2), so the N+1 can’t come back unnoticed.”

Likely follow-ups

  • select_related or prefetch_related? select_related joins in one query and works for a single related row. prefetch_related runs 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 Prefetch object with its own queryset, and to_attr to 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_related on 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.

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.

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
Okay

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.
Better

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.
Best

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.

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
Okay

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.
Better

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.
Best

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
Okay

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.
Better

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.
Best

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:

@property
def 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
Okay

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.
Better

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.
Best

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.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputThe page shows 20 orders. How many queries does it send?
    # view
    orders = Order.objects.order_by("-order_date")
    
    {# template #}
    {% for order in orders %}
      {{ order.customer.name }} {{ order.items.count }}
    {% endfor %}
  2. Multiple choiceEach order has one customer (a ForeignKey). Which call loads the customers with the orders in one query?
  3. Spot the bugThis code uses prefetch_related("items"), but it still sends one query per order. Why?
    orders = Order.objects.prefetch_related("items")
    for order in orders:
        big = order.items.filter(quantity__gt=1)
        print(order.id, len(big))
  4. Fill in the answerFill in the call that makes the database count each order's items: Order.objects.____(item_count=Count("items"))
  5. Pick the patternYou fixed the N+1. How do you stop it from coming back next month?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • 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 a ForeignKey or OneToOneField.
  • prefetch_related("items"): one more query with IN (…), for reverse foreign keys and ManyToManyField.
  • 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 assertNumQueries or django_assert_num_queries.

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