Django production problems / ORM

Find Django N+1 queries that matter in production

A client website gets slower as its data grows. Find the affected route, reproduce repeated queries locally, and keep the fix from regressing.

Test the query fix

No account required · Local SQLite example · Updated September 8, 2026

What makes a query pattern N+1?

One query loads a list; accessing a related object for each row then triggers another query per row. Five orders can therefore need six queries just to read their customers' names. A slow request alone does not establish N+1: one expensive query, a lock, an external API or Python work can also dominate the response.

For a foreign key or one-to-one relation, select_related() can load related data in the same query. For collections such as many-to-many or reverse foreign keys, inspect prefetch_related(), which uses separate queries. Choose the relation you actually read. See the Django QuerySet reference.

1. Start with one route and a defined window

Pick the website and normalized route that needs attention. Note the environment, release, time window and number of observed requests. Compare similar traffic and result sizes; a list of twenty records is not the same workload as a list of one.

  • Request duration: how long the observed request took. It includes more than database work.
  • Query count: whether database calls grow with the number of displayed records.
  • Database time: time around query execution. It is not a database-server CPU measurement.
  • Repeated query observations: a lead for investigation, not proof that every repetition is avoidable.

If database time dominates but counts stay flat, investigate the slow query and its execution plan. If database time is small, inspect other work before adding ORM optimizations. Django's optimization guidance starts with measurement.

2. Reproduce query growth locally

This example creates customers and orders in a fresh in-memory SQLite database. It tests one, five and twenty orders, then checks that the optimized query returns identical names. It does not load your website settings or connect to production.

Download the script into a local development directory. With Python 3.11+ and Django 5.2 installed in your development environment, run:

python django_n_plus_one.py
Download the complete Python example
Inspect the complete script before running it
"""A local N+1 regression example. Requires Django 5.2.

Run: python django_n_plus_one.py
Uses a fresh in-memory SQLite database, no website settings or network calls.
"""

import unittest

import django
from django.conf import settings

settings.configure(
    INSTALLED_APPS=[],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    DEFAULT_AUTO_FIELD="django.db.models.AutoField",
)
django.setup()

from django.db import connection, models
from django.test.utils import CaptureQueriesContext


class Customer(models.Model):
    name = models.CharField(max_length=80)

    class Meta:
        app_label = "n_plus_one_example"


class Order(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)

    class Meta:
        app_label = "n_plus_one_example"


def customer_names(orders):
    return [order.customer.name for order in orders]


class QueryGrowthTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        with connection.schema_editor() as editor:
            editor.create_model(Customer)
            editor.create_model(Order)

    @classmethod
    def tearDownClass(cls):
        with connection.schema_editor() as editor:
            editor.delete_model(Order)
            editor.delete_model(Customer)

    def test_same_output_without_query_growth(self):
        for size in (1, 5, 20):
            with self.subTest(orders=size):
                Order.objects.all().delete()
                Customer.objects.all().delete()
                for index in range(size):
                    customer = Customer.objects.create(name=f"Customer {index}")
                    Order.objects.create(customer=customer)

                with CaptureQueriesContext(connection) as before:
                    original = customer_names(Order.objects.order_by("id"))
                with CaptureQueriesContext(connection) as after:
                    fixed = customer_names(
                        Order.objects.select_related("customer").order_by("id")
                    )

                self.assertEqual(original, [f"Customer {i}" for i in range(size)])
                self.assertEqual(fixed, original)
                self.assertEqual(len(before), size + 1)
                self.assertEqual(len(after), 1)
                print(f"{size} orders: {len(before)} -> {len(after)} queries; same output")


if __name__ == "__main__":
    unittest.main()

Measured locally with Django 5.2 and SQLite on September 8, 2026:

1 orders: 2 -> 1 queries; same output
5 orders: 6 -> 1 queries; same output
20 orders: 21 -> 1 queries; same output

These are counts for the example's read operation, excluding fixture creation. They are not customer results, latency benchmarks or a promise of the same improvement on your website.

3. Fix the relation access and test the real output

In the script, each access to order.customer.name needs a customer lookup unless the customer is already loaded. The changed queryset joins the customer:

# Before
customer_names(Order.objects.order_by("id"))

# After
customer_names(
    Order.objects.select_related("customer").order_by("id")
)

Move the same idea into the code path your website actually uses. Exercise the template or serializer that accesses the relation; testing only queryset construction misses lazy evaluation. Create a fresh queryset for each measurement so a cached result cannot hide work.

Keep a regression test with several related rows, unchanged output and a bounded query count. In a Django TestCase, assertNumQueries() can enforce the budget. Include authentication or other queries when testing a complete request; the one-query budget above applies only to this example.

4. Verify the deployed route under comparable conditions

After deploying through your normal release process, compare the same route and similar result sizes in explicit before/after windows. Check query counts, database time, request duration and errors. A lower query count does not guarantee lower latency: larger joins and extra prefetched data also have costs.

Record the change, the evidence and what remains uncertain. Do not claim recovered revenue or that FetchNode caused an improvement from an aggregate traffic change.

Keep watching the websites you maintain

Use production signals to choose the next investigation

FetchNode's Django client records request duration, database query count, database time and bounded query observations for captured requests. Raw SQL text is disabled by default. Repeated query patterns provide investigation leads; they do not automatically prove an N+1 bug.

These observations cover queries executed through the instrumented default Django database connection during a request. They are not complete distributed traces or a record of every database alias and background job. Use a specialist profiler or database tool when those signals are insufficient.

Inspect the interactive product demo, then use the client website maintenance checklist to repeat the review across your portfolio.

Monitor my first Django site

No credit card · Inspect the setup · Verify a real production signal

Written by FetchNode. Technical references checked September 8, 2026. The runnable example and its query counts are maintained with this page.