Blogs / Consultas N+1 en Django ORM: Encuéntralas y Elimínalas

Consultas N+1 en Django ORM: Encuéntralas y Elimínalas

Publicado
3 de septiembre de 2026
Autor
Faizan Nadeem
Etiquetas
Django Python Backend Development
Vista aérea de un laberinto circular de setos en un parque, con anillos concéntricos de senderos que convergen en un árbol desnudo en el centro
Foto de Ben Mathis Seibel en Unsplash

La mayor fortaleza del ORM de Django es también su mayor trampa: order.customer.name parece un acceso a atributo gratuito, pero detrás de ese punto hay una decisión sobre si Django ya tiene esos datos en memoria o está a punto de lanzar una consulta SQL completamente nueva para obtenerlos. La mayoría de las veces, en un bucle de plantilla o en un serializer, lanza esa consulta una vez por fila; y como cada consulta individual es rápida, nadie lo nota hasta que la página que tardaba 40ms en local tarda cuatro segundos con datos de producción, con mil filas en lugar de diez.

Esa es la forma específica de un problema N+1: una consulta para obtener una lista y luego N consultas más —una por elemento— para obtener un objeto relacionado por cada fila. No es un bug de Django; es la consecuencia natural de la carga perezosa de relaciones combinada con código que nunca dice explícitamente “y carga también las filas relacionadas en bloque”. La solución no es adivinar ni sobrecargar todo por defecto: es identificar exactamente qué relación está disparando las consultas repetidas y elegir la herramienta adecuada, de entre un pequeño conjunto, que se ajuste a la forma real de esa relación.

Aprenderás:

  • Cómo ver consultas N+1 en una traza real de petición, no solo sospechar de ellas
  • Por qué el acceso perezoso a relaciones es la causa de N+1 desde el principio
  • Cuándo select_related es la solución correcta y por qué no funciona para todos los tipos de relaciones
  • Cuándo prefetch_related es la solución correcta y qué hace realmente distinto por debajo
  • Cómo los objetos Prefetch manejan querysets relacionados filtrados y ordenados
  • Cuándo ninguna de las dos herramientas es la adecuada y la agregación raw es una mejor respuesta
  • Cómo hacer que las regresiones N+1 fallen en CI antes de llegar a producción

Tabla de contenidos

  1. Cómo se ve realmente N+1
  2. Cómo verlo: herramientas para detectar N+1 en condiciones reales
  3. Por qué la carga perezosa provoca esto
  4. select_related: para relaciones directas y uno a uno
  5. prefetch_related: para relaciones inversas y muchos a muchos
  6. Objetos Prefetch: filtrado y ordenación de datos relacionados
  7. Cuando ninguna sirve: agregación en lugar de iteración
  8. Detectar regresiones en CI
  9. Errores comunes

Cómo se ve realmente N+1

Tomemos una página que lista pedidos recientes con el nombre de cada cliente:

orders = Order.objects.filter(status="pending")[:50]
for order in orders:
    print(order.customer.name)

Esto parece 51 líneas de código inofensivo. Son 51 consultas: un SELECT para el queryset inicial de orders, y luego un SELECT ... WHERE id = ? adicional por cada acceso a order.customer, porque customer es una clave foránea que Django carga de forma perezosa en el primer acceso, no de forma anticipada cuando se obtuvo el pedido. Cincuenta filas significan cincuenta viajes extra a la base de datos, cada uno pagando el coste completo de conexión y red para una búsqueda de una sola fila que podría haber sido un único JOIN.

Este es exactamente el patrón de bucle y búsqueda que aparece como un nested loop con un alto recuento de iteraciones en un plan de Postgres; consulta la sección de loops de la guía de EXPLAIN ANALYZE para ver el mismo problema desde el lado de la base de datos en lugar del lado del ORM. Ambos son el mismo bug con distinta apariencia: una operación que parece barata repetida una vez por fila en lugar de agruparse una sola vez para todo el conjunto.

Cómo verlo: herramientas para detectar N+1 en condiciones reales

Intentar adivinar N+1 en una revisión de código pasa por alto casos reales y marca falsos positivos constantemente; la única forma fiable de encontrarlos es mirar el recuento real de consultas de una petición real.

Django Debug Toolbar es la opción local más rápida: cuenta cada consulta ejecutada por petición, muestra consultas duplicadas lado a lado y señalará visiblemente “similar queries” cuando se repita la misma forma de SELECT ... WHERE id = ? a lo largo de una respuesta.

django.test.utils.CaptureQueriesContext hace lo mismo de forma programática, lo que importa porque puede usarse en tests, no solo en una sesión interactiva del navegador:

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

with CaptureQueriesContext(connection) as ctx:
    list(Order.objects.filter(status="pending")[:50])
    for order in Order.objects.filter(status="pending")[:50]:
        _ = order.customer.name

print(len(ctx.captured_queries))  # 51, not 2

django-silk y herramientas APM como el monitoreo de rendimiento de Sentry o el APM de Datadog van más allá en producción: atribuyen el recuento de consultas y el tiempo acumulado a vistas específicas e incluso a líneas concretas, que es como detectas patrones N+1 que solo aparecen con volumen real de datos y fan-out real de relaciones, no con los pequeños datos de prueba que se usan localmente.

La señal a buscar en cualquiera de estas herramientas es la misma: una consulta casi idéntica, diferenciándose solo en un parámetro de la cláusula WHERE, repetida docenas o cientos de veces en una sola petición. Esa repetición, y no solo el número total de consultas, es la huella real de N+1.

Por qué la carga perezosa provoca esto

El ORM de Django es perezoso por diseño: un queryset no toca la base de datos hasta que se itera, y un descriptor de objeto relacionado no toca la base de datos hasta que se accede a él. Este es un valor por defecto deliberado y, en general, bueno: significa que Order.objects.filter(...) puede construirse a lo largo de varias líneas, pasarse de un sitio a otro y filtrarse aún más sin lanzar una consulta prematuramente.

El coste de esa pereza es que Django no tiene forma de saber, en el punto donde escribes orders = Order.objects.filter(...), que tu siguiente línea va a tocar .customer en todas y cada una de las filas. Cada acceso a order.customer es una decisión completamente independiente desde el punto de vista de Django: no recuerda que ya respondió al mismo tipo de pregunta hace cuarenta y nueve filas. select_related y prefetch_related existen específicamente para decirle a Django, por adelantado: “ya sabes que voy a necesitar estos datos relacionados para cada fila; tráelos todos ahora, en una o dos consultas, en lugar de esperar a que te los pida una fila a la vez”.

Vale la pena ser precisos sobre qué se cachea, porque es una fuente habitual de confusión. Una vez que un queryset ha sido evaluado (iterado una vez, troceado o forzado con list()), sus resultados se almacenan en caché en esa instancia de queryset; iterarlo una segunda vez no vuelve a lanzar la consulta. Pero esa caché vive en el propio objeto queryset, no en las instancias del modelo que produjo, y no se extiende en absoluto a las búsquedas de objetos relacionados. order.customer la primera vez y order.customer la segunda vez sobre el mismo objeto order sí aprovecha la caché de relación por instancia de Django y solo consulta una vez; pero una llamada nueva a Order.objects.filter(...) un momento después empieza desde cero, sin memoria de nada de lo que el queryset anterior ya obtuvo. Esto es exactamente por lo que N+1 aparece específicamente en bucles: cada iteración produce una instancia de modelo distinta con su propia caché de relación vacía, así que el ahorro de “esto ya se preguntó” nunca se acumula entre filas.

Vistas asíncronas y recuento de consultas

Las vistas asíncronas de Django (async def get(self, request), disponibles desde 4.1) no cambian ninguna de las mecánicas anteriores: select_related y prefetch_related funcionan de forma idéntica, y las llamadas al ORM envueltas con sync_to_async siguen agrupándose de la misma manera. Lo que cambia es lo fácil que es pasar por alto un problema N+1: bajo asyncio, las peticiones concurrentes se intercalan en el mismo worker, así que una vista lenta y cargada de consultas no bloquea todo el proceso como lo haría una síncrona, y una regresión en el número de consultas puede quedar oculta tras una latencia aparente aceptable hasta que sube la concurrencia. El enfoque de depuración no cambia —CaptureQueriesContext y assertNumQueries funcionan igual con clientes de prueba async def—, pero trata lo asíncrono como una razón para probar los recuentos de consultas de forma más deliberada, no como una razón para que importe menos.

select_related funciona generando un SQL JOIN y trayendo las columnas de la fila relacionada en la misma consulta que la fila padre: un viaje, un conjunto de resultados, objetos relacionados ya poblados cuando accedes a ellos.

orders = Order.objects.filter(status="pending").select_related("customer")
for order in orders:
    print(order.customer.name)  # no extra query — already joined

Esto solo funciona para relaciones donde cada fila tiene exactamente una fila relacionada con la que hacer join: ForeignKey y OneToOneField, seguidas en la dirección “directa” (de Order a su Customer, no a la inversa). Puedes encadenar varios saltos en una sola llamada:

Order.objects.select_related("customer__account__billing_address")

Cada salto adicional añade columnas a la misma consulta con join en lugar de una consulta nueva, así que esto sigue siendo un solo viaje sin importar lo profunda que sea la cadena; la contrapartida es un conjunto de resultados más ancho por fila, lo que importa si las tablas unidas tienen muchas columnas que en realidad no necesitas. select_related no puede ayudar con claves foráneas inversas ni con relaciones muchos a muchos, porque un JOIN que pudiera devolver múltiples filas relacionadas por cada fila padre rompe la forma de una-fila-por-padre que un JOIN produce de manera natural; ese es exactamente el caso para el que existe prefetch_related.

prefetch_related adopta una estrategia completamente distinta: en lugar de una única consulta con JOIN, ejecuta una segunda consulta separada que obtiene todas las filas relacionadas para todo el lote de una vez y luego las asocia a los objetos padre correctos en Python.

customers = Customer.objects.filter(active=True).prefetch_related("orders")
for customer in customers:
    for order in customer.orders.all():  # no extra query per customer
        print(order.total)

Eso son dos consultas en total, sin importar si hay 10 clientes o 10.000: un SELECT * FROM customers WHERE active, y un SELECT * FROM orders WHERE customer_id IN (...) que cubre todos los clientes del primer conjunto de resultados. Luego Django agrupa el segundo conjunto de resultados por customer_id en Python y adjunta cada pedido a su cliente correspondiente, de modo que customer.orders.all() dentro del bucle no vuelve a tocar la base de datos.

Esta es la herramienta correcta (y la única) para claves foráneas inversas (customer.orders, el lado “muchos” mirando hacia el “uno”), campos muchos a muchos y cualquier relación donde una sola fila padre pueda tener múltiples filas relacionadas coincidentes; un JOIN no puede representar eso sin duplicar las filas padre, así que el enfoque de consulta separada más fusión en Python de prefetch_related es estructuralmente necesario, no solo un estilo alternativo. Elegir entre select_related y prefetch_related depende por completo de la cardinalidad de la relación: una fila relacionada por padre usa select_related; potencialmente muchas filas relacionadas por padre usan prefetch_related.

select_relatedprefetch_related
Forma de la relaciónuna fila relacionada por padrecero, una o muchas filas relacionadas por padre
Se aplica aForeignKey, OneToOneField (directa)FK inversa, ManyToManyField, GenericRelation
Mecanismouna consulta, SQL JOINdos (o más) consultas, fusionadas en Python
Número de consultassiempre 1 para toda la cadena1 + 1 por relación precargada
Puede filtrar/ordenar el conjunto relacionadono (el join devuelve columnas completas del padre)sí, mediante objetos Prefetch

Esa última fila merece interiorizarse por sí sola: select_related no puede acotar qué fila relacionada vuelve, porque un JOIN o coincide o no coincide; no existe el concepto de “dame solo la coincidencia más reciente” dentro de un join simple. Siempre que una búsqueda relacionada necesite su propio filtro u ordenación, estás en territorio de prefetch_related aunque nominalmente la relación sea de una sola fila por padre, porque un objeto Prefetch es el único mecanismo que acepta un queryset personalizado.

prefetch_related("orders") obtiene todos los pedidos relacionados. A menudo solo quieres un subconjunto filtrado u ordenado —solo pedidos pendientes, o solo los cinco más recientes— y un argumento de cadena simple no puede expresarlo. Los objetos Prefetch sí pueden:

from django.db.models import Prefetch

recent_pending = Prefetch(
    "orders",
    queryset=Order.objects.filter(status="pending").order_by("-created_at"),
    to_attr="recent_pending_orders",
)

customers = Customer.objects.prefetch_related(recent_pending)
for customer in customers:
    for order in customer.recent_pending_orders:  # already filtered, already ordered
        print(order.total)

El argumento queryset te permite filtrar, ordenar o incluso aplicar select_related adicional dentro del propio prefetch; un Prefetch puede anidar un select_related dentro de sí para cubrir una relación uno a uno colgando de una relación muchos a muchos, y seguir en exactamente dos consultas totales. El argumento to_attr guarda el resultado filtrado bajo un nuevo nombre de atributo en lugar de sobrescribir el manager por defecto, lo cual importa porque reutilizar el mismo objeto customer en otra parte de la petición con una llamada sin filtrar a .orders.all() podría, de lo contrario, disparar silenciosamente una consulta nueva y no precargada.

Cuando ninguna sirve: agregación en lugar de iteración

A veces el objetivo real no es “dame cada objeto relacionado para cada fila”, sino un único número o un pequeño conjunto de números por fila, como un recuento de pedidos o un total. Obtener objetos relacionados completos con prefetch_related solo para aplicarles len() o sum() en Python es trabajo desperdiciado; la base de datos puede calcular esa agregación directamente, en una sola consulta, sin materializar en absoluto las filas relacionadas individuales como objetos de Python.

from django.db.models import Count, Sum

customers = Customer.objects.annotate(
    order_count=Count("orders"),
    lifetime_total=Sum("orders__total"),
)
for customer in customers:
    print(customer.order_count, customer.lifetime_total)  # no related objects fetched at all

Esta es una solución genuinamente distinta de las dos anteriores, y es la correcta siempre que el objetivo final sea un número, no las filas relacionadas en sí. annotate() empuja la agregación a la base de datos —el mismo lugar donde GROUP BY y COUNT() ya hacen este trabajo eficientemente— en lugar de traer cada fila relacionada por la red solo para volver a reducirla en Python.

Detectar regresiones en CI

La mejor solución para N+1 no es una limpieza puntual, sino hacer que una regresión falle en un test antes de desplegarse. assertNumQueries de django-test-plus, o la propia versión integrada de Django, fija el número esperado de consultas para una vista o una ruta de código:

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

def test_order_list_view_query_count(self):
    with self.assertNumQueries(2):
        response = self.client.get("/orders/")
        self.assertEqual(response.status_code, 200)

Un test como este falla de forma evidente en cuanto alguien añade un nuevo acceso a order.customer.name en una plantilla o serializer sin actualizar también la cadena correspondiente de select_related/prefetch_related del queryset, capturando la regresión en una ejecución de CI de dos segundos en lugar de en un panel APM de producción semanas después. Fija primero el recuento en las vistas de lista y detalle con más tráfico; ahí es donde el fan-out de N+1 hace más daño por petición y donde un test fijo se amortiza más rápido.

Errores comunes

Error: intentar usar select_related sobre una clave foránea inversa. Django lanzará un FieldError: select_related realmente no puede seguir una relación donde podrían coincidir múltiples filas. Solución: usa prefetch_related para cualquier cosa en el lado “muchos”, de forma automática.

Error: precargar una relación y luego volver a filtrarla con .filter() dentro del bucle. customer.orders.filter(status="pending") dentro de un bucle después de prefetch_related("orders") dispara una consulta completamente nueva por fila, porque una llamada nueva a .filter() es un queryset distinto del prefetch cacheado. Solución: aplica el filtro por adelantado dentro del argumento queryset de un objeto Prefetch, y usa to_attr para que no haya forma de volver accidentalmente a una llamada sin filtrar y sin precarga.

Error: sobrecargar con select_related a través de cadenas de joins muy amplias. Encadenar select_related cinco saltos de profundidad trae cada columna de cada tabla de la cadena en cada fila, incluso cuando en realidad solo se usa un campo de la tabla más profunda. Solución: usa .only() junto con select_related para restringir las columnas unidas, o replantea si ese join profundo es siquiera necesario para esa vista.

Error: asumir que un número total bajo de consultas significa que no hay un problema N+1. Diez consultas para diez filas siguen pasando una comprobación ingenua de “consultas por debajo de cierto umbral”, mientras siguen siendo un patrón N+1 real que empeorará linealmente a medida que crezca la tabla. Solución: busca específicamente la señal de forma de consulta repetida, no solo un recuento bruto, y vuelve a probar con recuentos de filas realistas, no con datos del tamaño de fixtures.

Error: arreglar N+1 en la vista pero no en el serializer. Los serializers de Django REST Framework con relaciones anidadas vuelven a disparar exactamente el mismo problema de carga perezosa, independientemente de lo que ya hiciera el queryset de la vista: un CustomerSerializer con un OrderSerializer(many=True) anidado lanzará tranquilamente una consulta por cliente si el queryset de la vista nunca llamó a prefetch_related("orders"), sin importar lo cuidadosamente que esté escrita la propia vista. Solución: aplica select_related/prefetch_related al queryset que realmente consume el serializer (normalmente en get_queryset() del ViewSet) y verifica con assertNumQueries contra la respuesta serializada, no solo contra el queryset en bruto; un serializer puede introducir sus propios puntos de acceso perezoso que un test solo del queryset nunca detectaría.

Para cerrar

Las consultas N+1 no son un bug de Django que haya que esquivar: son el coste predecible de la carga perezosa de relaciones al encontrarse con código que nunca le dice al ORM “agrupa esto”. La solución nunca es “añade prefetch_related en todas partes y cruza los dedos”, sino identificar la cardinalidad real de la relación (una fila o muchas), asociarla con select_related o prefetch_related según corresponda, recurrir a objetos Prefetch cuando el conjunto relacionado necesita filtrado u ordenación, y reconocer cuándo el objetivo real era un número agregado que la base de datos debería calcular directamente en lugar de una iteración que Python tenga que hacer a mano.

La base de datos subyacente aún tiene que ejecutar el número de consultas que produzca tu código ORM, así que una vez que el propio recuento de consultas sea correcto, también merece la pena leer el plan resultante: un join de select_related es tan rápido como el índice que lo respalda, y ahí es donde retoma el tema la guía de EXPLAIN ANALYZE.

¿Has comprobado el recuento de consultas de tu vista con más tráfico frente a sus recuentos reales de filas en producción, o solo frente a tus fixtures locales?

Más artículos