The N+1 Problem in Django's ORM and How to Avoid It

Video thumbnail

We are going to learn about a very common issue, known as the N + 1 problem, which happens frequently in these types of frameworks that use an ORM behind the scenes.

It is not an issue with the ORM itself, but rather with the implementation. Remember that this is just a tool, and everything depends on how we use it as developers. That is the first thing I want to make clear.

Previously we learned how to incorporate Tailwind CSS into Django 6

Prior recommendation before continuing

Before continuing, I recommend the following:

I recommend installing the django-debug-toolbar package, which we discussed in the chapter on Essential Packages, so you can detect these types of problems more easily.

Demonstration of the N + 1 problem

Now let's look at the problem itself. For this, we will do a simple demonstration.

We have a list of elements, like the one you can see here. The design doesn't matter at all. It is simply a list of elements, very similar to the one we made with comments. Here we have its associated view and we are iterating the elements with a "for" loop:

def index(request):
    elements = Element.objects.all()
{% for e in elements %}

   <div class="comment-card">

     <p>{{ e.id }} - {{ e.title }}</p>

So far, everything is normal.

Where is the problem?

The problem appears when we want to access, from the main entity (in this case Elements), its child relations, which would be the Foreign Keys. For example, the relationship with Category or Type.

If we check the model, we see that these relations point to other tables. By default, when we perform a query, Django only brings the information from the main table. However, thanks to the ORM, we can easily access related objects:

class Element(models.Model):
    ***
    category = models.ForeignKey(Category, on_delete=models.CASCADE) #, related_name='elements'

    type = models.ForeignKey(Type, on_delete=models.CASCADE)

For example, from an element we can access element.category.title. And this is where the problem starts:

{% for e in elements %}

   <div class="comment-card">

     <p>{{ e.id }} - {{ e.title }} - {{ e.category.title }}</p>

What happens internally

Before saving anything, if we check the queries using the Debug Toolbar, we see that initially we have few queries; everything seems correct.

But when we start accessing the category inside the loop, something changes.

If we have, for example, 8 elements, suddenly we see that 8 additional queries are executed, one for each element, plus the main query.

That is where the famous N + 1 appears:

  • N = number of records (8 in this case)
  • +1 = the main query

This is exactly the N + 1 problem.

Why the N + 1 problem occurs

This occurs because Django, like many other frameworks (for example Laravel), uses Lazy Loading by default.

What does Lazy Loading mean?

It means that relations are not loaded until they are needed.

So, what happens is the following:

  1. Django fetches the list of elements.
  2. In the first iteration of the loop, it sees that you need the category → it performs a query.
  3. In the second iteration, it needs the category again → another query.
  4. And so on.

That's why you see so many repeated queries in the Debug Toolbar:

SELECT COUNT(*) AS "__count" FROM "elements_element"
0.24	
Sel Expl
+	
SELECT ••• FROM "elements_element" LIMIT 8
0.32	
Sel Expl
+	
SELECT ••• FROM "elements_category" WHERE "elements_category"."id" = 1 LIMIT 21
 8 similar queries.  Duplicated 6 times.		0.32	
Sel Expl
+	
SELECT ••• FROM "elements_category" WHERE "elements_category"."id" = 1 LIMIT 21
 8 similar queries.  Duplicated 6 times.		0.22	
Sel Expl
+	
SELECT ••• FROM "elements_category" WHERE "elements_category"."id" = 1 LIMIT 21
 8 similar queries.  Duplicated 6 times.

This is not a bad design. In fact, it makes a lot of sense. Imagine that a model has 5, 6, or 7 relations, some very heavy. If Django fetched everything automatically, it would be a performance disaster in many cases.

That's why Lazy Loading is the default behavior.

When it is a problem

In a detail view, where you only show one element, performing 2 or 3 queries is not a problem.

But in a list, especially if it is not paginated or has many records, this becomes a serious performance issue.

That is where we, as developers, have to detect the problem and solve it.

How to detect the problem

The most convenient way is using django-debug-toolbar, which directly warns you about duplicated queries.

Another way is to activate SQL logging, configuring the logger to print queries to the console. Thus, when reloading the page, you will see all the executed queries and quickly notice the problem:

settings.py

LOGGING = {
    'version': 1,  # Esta es la línea que faltaba
    'disable_existing_loggers': False,
    'formatters': {
        'simple': {
            'format': '{levelname} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'formatter': 'simple',
        },
    },
    'loggers': {
        'django.db.backends': {
            'level': 'DEBUG',
            'handlers': ['console'],
            'propagate': False,
        },
    },
}

And you will see in the terminal SQL like:

DEBUG (0.000) SELECT "elements_category"."id", "elements_category"."title", "elements_category"."slug" FROM "elements_category" WHERE "elements_category"."id" = 2 LIMIT 21; args=(2,); alias=default     
DEBUG (0.000) SELECT "elements_category"."id", "elements_category"."title", "elements_category"."slug" FROM "elements_category" WHERE "elements_category"."id" = 2 LIMIT 21; args=(2,); alias=default     
DEBUG (0.000) SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > '2026-01-20 14:08:44.935139' AND "django_session"."session_key" = 'zj91gin4tv80pn5cjfqi83ijh7xc5q78') LIMIT 21; args=('2026-01-20 14:08:44.935139', 'zj91gin4tv80pn5cjfqi83ijh7xc5q78'); alias=default

Solution: select_related

The solution is quite simple.

When we know we are going to need the relations, we use select_related.

This tells Django to fetch the related relations in the same query, using a JOIN.

For example:

  • We fetch Elements
  • And at the same time we fetch Category and Type (only if we are going to use them)

After applying select_related, if we reload the page, you will see that we have only two queries again, and the main one now includes a JOIN.

elements = Element.objects.select_related('category', 'type').all()

Another solution: make it global from the model

If you are sure that you will always need certain relations (for example, that every post always shows its category), you can configure this at the model level using a custom Manager:

class ElementManager(models.Manager):
    def get_queryset(self):
        # Siempre que usemos Element.objects.all(), incluirá el select_related
        return super().get_queryset().select_related('category', 'type')

class Element(models.Model):
    ***
    
    # Asignamos el manager personalizado
    objects = ElementManager()

This makes it so that every time elements are queried, Django automatically includes the necessary relations. It is a global configuration.

However, you must be careful: this changes the default behavior and goes from Lazy Loading to Eager Loading.

Lazy Loading vs Eager Loading

Lazy Loading (default): more flexible and lightweight.

Eager Loading: more efficient in lists, but may fetch more data than necessary.

That's why Django uses Lazy Loading by default, and we decide when to change that behavior.

Next step: Inverse Relationships in Django and the N+1 Problem

Learn what the N+1 problem is in Django, why it occurs with the ORM, how to detect it, and how to optimize queries using select_related and eager loading.

I agree to receive announcements of interest about this Blog.

Andrés Cruz

ES En español