All articles
Django8 min readReal-world case study

The Django N+1 Select Problem: From 21 Queries to 2

An innocent loop can quietly turn one database query into hundreds. Here is how to recognize that pattern, fix it with Django’s ORM, and keep it from returning.

TR

Tsirimaholy Harison Razanapanala

Full-Stack Developer

Published · Updated

Diagram showing a Django endpoint reduced from 21 database queries to 2 with select_related and prefetch_related
The query count should stay bounded as the number of rows grows.

Why this mattered in production

At Numer, an analytics page had reached roughly four minutes of loading time. After profiling N+1 behavior, optimizing the SQL, and adding a purpose-built cache, it loaded in about 500–800 milliseconds.

That result was not one clever ORM call. It came from treating performance as an entire request-path problem: expose the hidden queries, reduce redundant database work, make the remaining SQL cheaper, and cache only the stable result.

I met the same class of problem later at Vertex while removing backend bottlenecks from a B2B wellbeing platform. There, N+1 and SQL optimization sat beside another boundary: deciding which work belonged in the request and which belonged in asynchronous Celery and Redis processing.

About the example below: the 21-to-2 query case uses a simplified user-management data model. It explains the technical pattern without exposing client code or private data.

The deeper cause: object–relational impedance mismatch

Application code naturally speaks in objects and relationships: a user has a profile and belongs to roles. A relational database stores rows in separate tables and is strongest when it processes data in sets. An ORM connects those two models, but it cannot erase the difference between them.

That difference is the impedance mismatch. Readinguser.profilelooks like an ordinary in-memory property access, while the data may actually require a network round trip and a SQL query. Lazy loading keeps code convenient, but it can hide I/O inside a loop, template, or serializer.

What the N+1 select problem means

The N+1 select problem happens when an application runs one query to load a collection, then runs another query for every item in that collection to load related data.

The context

Imagine a GET /api/users endpoint that returns a directory. Each row needs the user’s name, profile details, and assigned roles. Django first loads the users, but the serializer then discovers that it also needs two relationships for every row.

Initial query

1

Load all users

Related queries

N × 2

Profile and roles per user

For 10 users

21

Total queries

Django relationships are lazy by default. This is useful: the ORM does not fetch data that you may never use. The surprise comes when a template, serializer, or loop accesses a related field and triggers hidden database work for every object.

A real example: users, profiles, and roles

In a user-management API, each user had one profile and could have multiple roles. This was the relationship being loaded:

Simplified data model

User

id · name · email

1 : 1

UserProfile

phone · address

1 : many

Role

name · permissions

1 query: usersN queries: profilesN queries: roles

The straightforward code looked harmless:

views.py — before optimization
users = User.objects.all()

for user in users:
    profile = user.userprofile
    roles = user.role_set.all()

The first line executes one query for the users. Accessing user.userprofile adds one query per user, and calling user.role_set.all() adds another.

SQL — repeated for every user
SELECT * FROM auth_user;

SELECT * FROM userprofile
WHERE userprofile.user_id = <user_id>;

SELECT * FROM role
WHERE role.user_id = <user_id>;

The query count grows with the result size. Ten users produce 21 queries; 100 users produce 201. This is why the endpoint can look fast in development and degrade sharply with production data.

The fix: load the data intentionally

The solution is eager loading, but each relationship needs the right strategy:

views.py — optimized queryset
users = (
    User.objects
    .select_related("userprofile")
    .prefetch_related("role_set")
)

for user in users:
    profile = user.userprofile
    roles = user.role_set.all()

Result

212queries

One joined query loads users and profiles. One additional query loads every role needed for those users, and Django joins the results in Python.

`select_related` or `prefetch_related`?

MethodBest forHow it works
select_relatedForeignKey and OneToOneFieldA SQL JOIN in one query
prefetch_relatedManyToMany and reverse relationshipsA second query, combined in Python

More eager loading is not automatically better. Large joins can duplicate rows, and prefetching unused relationships costs memory and network bandwidth. Start from what the endpoint actually reads, then fetch that graph deliberately.

GraphQL does not remove the N+1 select problem by itself. A GraphQL resolver can create the same pattern unless the server batches and caches loads with a tool such as DataLoader.

How to detect it before production

  • Inspect requests with Django Debug Toolbar during development.
  • Look for the same SQL statement repeated with only an ID changing.
  • Use assertNumQueries in tests so query count does not grow with fixture size.
  • Measure with realistic data; five local rows can hide a scaling problem.
  • Review serializers and templates, where relationship access is easy to overlook.
tests.py — protect the query budget
def test_user_list_query_count(self):
    UserFactory.create_batch(10)

    with self.assertNumQueries(2):
        response = self.client.get("/api/users/")

    self.assertEqual(response.status_code, 200)

The takeaway

Query count should follow the shape of the request, not the number of rows.

The ORM is not the enemy; invisible I/O is. Once you make the data-loading plan explicit and protect it with a query-count test, Django gives you both readable code and predictable performance.