Problema del N+1 en el ORM de Django y como evitarlo

Video thumbnail

Vamos a conocer un problema muy común, conocido como el problema del N + 1, que sucede muchísimo en este tipo de frameworks que emplean un ORM por detrás.

No es que sea un problema del ORM en sí, sino más bien de implementación. Recuerda que esto es solo una herramienta y todo depende de cómo la empleemos nosotros como desarrolladores. Eso es lo primero que quiero dejar claro.

Anteriormente aprendimos a como podemos incorporar Incorporar Tailwind CSS en Django 6

Recomendación previa antes de continuar

Antes de continuar, te recomiendo lo siguiente:

Te recomiendo que instalar el paquete de django-debug-toolbar del cual hablamos en el capitulo de Paquetes imprescindibles para que puedas detectar este tipo de problemas mas fácilmente.

Demostración del problema N + 1

Vamos ahora al problema en sí. Para eso haremos una demostración sencilla.

Tenemos un listado de elementos, como el que puedes ver aquí. El diseño no importa para nada. Es simplemente un listado de elementos, muy similar al que hicimos con comentarios. Aquí tenemos su vista asociada y estamos iterando los elementos con un for:

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

   <div class="comment-card">

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

Hasta aquí, todo normal.

¿Dónde está el problema?

El problema aparece cuando queremos acceder, desde la entidad principal (en este caso Elementos), a sus relaciones hijas, que serían las Foreign Keys. Por ejemplo, la relación con Categoría o Tipo.

Si revisamos el modelo, vemos que estas relaciones apuntan a otras tablas. Por defecto, cuando hacemos una consulta, Django solo trae la información de la tabla principal. Sin embargo, gracias al ORM, podemos acceder fácilmente a los objetos relacionados:

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

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

Por ejemplo, desde un elemento podemos acceder a element.categoria.title. Y aquí es donde empieza el problema:

{% for e in elements %}

   <div class="comment-card">

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

Qué sucede internamente

Antes de guardar nada, si revisamos las consultas usando el Debug Toolbar, vemos que inicialmente tenemos pocas consultas, todo parece correcto.

Pero cuando empezamos a acceder a la categoría dentro del loop, algo cambia.

Si tenemos, por ejemplo, 8 elementos, de repente vemos que se ejecutan 8 consultas adicionales, una por cada elemento, más la consulta principal.

Ahí es donde aparece el famoso N + 1:

  • N = número de registros (8 en este caso)
  • +1 = la consulta principal

Este es exactamente el problema N + 1.

Por qué ocurre el problema N + 1

Esto ocurre porque Django, al igual que muchos otros frameworks (por ejemplo Laravel), utiliza por defecto Lazy Loading.

¿Qué significa Lazy Loading?

Significa que las relaciones no se cargan hasta que se necesitan.

Entonces, lo que pasa es lo siguiente:

  1. Django trae la lista de elementos.
  2. En la primera iteración del loop, ve que necesitas la categoría → hace una consulta.
  3. En la segunda iteración, vuelve a necesitar la categoría → otra consulta.
  4. Y así sucesivamente.

Por eso ves tantas consultas repetidas en el 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.

Esto no es un mal diseño. De hecho, tiene mucho sentido. Imagínate que un modelo tiene 5, 6 o 7 relaciones, algunas muy pesadas. Si Django trajera todo automáticamente, sería un desastre de rendimiento en muchos casos.

Por eso el Lazy Loading es el comportamiento por defecto.

Cuándo sí es un problema

En una vista de detalle, donde solo muestras un elemento, hacer 2 o 3 consultas no es un problema.

Pero en un listado, especialmente si no está paginado o tiene muchos registros, esto se convierte en un problema serio de rendimiento.

Ahí es donde nosotros, como desarrolladores, tenemos que detectar el problema y solucionarlo.

Cómo detectar el problema

La forma más cómoda es usando django-debug-toolbar, que te avisa directamente de consultas duplicadas.

Otra forma es activar el logging de SQL, configurando el logger para que imprima las consultas por consola. Así, al recargar la página, verás todas las consultas ejecutadas y notarás rápidamente el problema:

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,
        },
    },
}

Y veras en la terminal SQL como:

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

Solución: select_related

La solución es bastante sencilla.

Cuando sabemos que vamos a necesitar las relaciones, usamos select_related.

Esto le indica a Django que traiga las relaciones relacionadas en la misma consulta, usando un JOIN.

Por ejemplo:

  • Traemos Elementos
  • Y al mismo tiempo traemos Categoría y Tipo (solo si los vamos a usar)

Después de aplicar select_related, si recargamos la página, verás que volvemos a tener solo dos consultas, y la principal ahora incluye un JOIN.

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

Otra solución: hacerlo global desde el modelo

Si estás seguro de que siempre vas a necesitar ciertas relaciones (por ejemplo, que cada post siempre muestre su categoría), puedes configurar esto a nivel de modelo usando un Manager personalizado:

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

Esto hace que, cada vez que se consulten los elementos, Django automáticamente incluya las relaciones necesarias. Es una configuración global.

Eso sí, hay que tener cuidado: esto cambia el comportamiento por defecto y pasa de Lazy Loading a Eager Loading.

Lazy Loading vs Eager Loading

Lazy Loading (por defecto): más flexible y liviano.

Eager Loading: más eficiente en listados, pero puede traer más datos de los necesarios.

Por eso Django usa Lazy Loading por defecto, y nosotros decidimos cuándo cambiar ese comportamiento.

Siguiente paso Relaciones Inversas en Django y Problema del N+1

Aprende qué es el problema N+1 en Django, por qué ocurre con el ORM, cómo detectarlo y cómo optimizar consultas usando select_related y eager loading.

Acepto recibir anuncios de interes sobre este Blog.

Andrés Cruz

EN In english