Comprender el problema de consultas N+1
Explicación del problema N+1 con soluciones en Rails, Django, Hibernate y Laravel, además de eager loading, JOIN y detección.
El problema de consultas N+1 ocurre cuando una aplicación ejecuta una consulta para obtener una lista de N registros y luego ejecuta una consulta adicional por cada registro para cargar una entidad relacionada: N+1 consultas donde bastaría con una o dos.
La mayoría de los desarrolladores se topan con él de la misma manera. Una página que se sentía instantánea contra una base de datos de desarrollo con datos sembrados se arrastra en cuanto llega a datos reales, y el log de consultas resulta ser el mismo SELECT repetido cuatrocientas veces con un id distinto.
Es el bug de rendimiento más común en código que usa un mapeador objeto-relacional, y aparece de forma idéntica en Rails, Django, Hibernate y Laravel porque todos comparten el mismo comportamiento por defecto: lazy loading (carga diferida). Este artículo define el patrón, lo muestra en cuatro stacks, mapea la solución exacta para cada framework, corrige un malentendido persistente sobre el eager fetching y explica cómo detectar el N+1 antes de que llegue a producción.
Puntos clave
- El problema de consultas N+1 es una consulta para cargar N filas padre más N consultas de seguimiento para cargar un registro relacionado por cada una. El número de consultas escala linealmente con N.
- Ocurre porque la mayoría de los ORM cargan las asociaciones de forma diferida por defecto, así que acceder a una relación dentro de un bucle dispara silenciosamente una consulta en cada iteración.
- Hay dos soluciones correctas, y ambas reducen N+1 a un número constante de consultas: un único JOIN que carga padres e hijos juntos, o una segunda consulta por lotes usando
WHERE id IN (...). - Establecer
FetchType.EAGERen JPA no soluciona el N+1 con JPQL. Cambia cuándo se disparan las consultas adicionales, no si se agrupan por lotes. - Los logs de consultas en desarrollo solo detectan el N+1 en las rutas de código que llegas a ejercitar; un APM en producción detecta aquellas que tu conjunto de datos local era demasiado pequeño para revelar.
¿Qué es el problema de consultas N+1?
Tomemos una relación posts/authors. Ejecutas una consulta para cargar todos los posts, luego iteras sobre ellos y lees post.author.name en cada uno. Ese acceso a la propiedad es una segunda consulta, repetida una vez por post. Diez posts producen once consultas; mil posts producen mil una, y el tiempo total de respuesta crece linealmente con el número de registros.
El crecimiento lineal es lo que hace peligroso al N+1. Un bug N+1 suele ser invisible en desarrollo contra cinco filas sembradas y se convierte en una caída del servicio en producción contra cinco mil. El endpoint que respondía en 40 ms en tu portátil responde en 4 segundos, o se agota por timeout, en cuanto llegan los datos reales.
¿Por qué ocurre el N+1?
Discover how at OpenReplay.com.
El N+1 ocurre porque la mayoría de los ORM cargan las asociaciones de forma diferida por defecto: un objeto relacionado no se obtiene cuando cargas el padre, sino en el primer acceso. Las relaciones de Eloquent se comportan exactamente así. Leer una como propiedad dispara la consulta en el momento del acceso, en lugar de cuando se cargó el modelo padre, y el eager loading es la alternativa opcional. Lo mismo vale para los proxies de ActiveRecord, los related managers de Django y los proxies diferidos de Hibernate.
Dentro de un bucle, ese acceso diferido es silencioso y se produce en cada iteración. Nada en el código fuente lo señala: ninguna palabra clave N+1, ninguna advertencia. Precisamente por eso sobrevive a la revisión de código y solo aflora bajo carga.
Cómo se ve en el código
El antes/después tiene la misma forma en todos los stacks: un bucle que toca una relación, reescrito para cargar esa relación por adelantado.
Rails (ActiveRecord):
# N+1: 1 query for books + 1 per book for the author
Book.limit(10).each { |book| puts book.author.last_name }
# Fixed: 2 queries total
Book.includes(:author).limit(10).each { |book| puts book.author.last_name }
Django ORM:
# N+1: 1 query for books + 1 per book for the author
for book in Book.objects.all():
print(book.title, book.author.name)
# Fixed: one JOIN
for book in Book.objects.select_related("author"):
print(book.title, book.author.name)
JPA / Hibernate (JPQL):
// N+1: findAll() loads transports, then one SELECT per driver on access
List<Transport> all = transportRepository.findAll();
// Fixed: a single fetch join
@Query("SELECT t FROM Transport t JOIN FETCH t.driver")
List<Transport> findAllWithDriver();
SQL puro: sustituye la búsqueda fila por fila con un único LEFT JOIN, usando LEFT para conservar los padres que no tienen hijos:
SELECT c.id, c.name, i.id AS item_id, i.name AS item_name
FROM categories c
LEFT JOIN items i ON i.category_id = c.id
ORDER BY c.name, i.name;
Cómo solucionar el problema de consultas N+1
Hay dos formas correctas de resolver el N+1, y ambas lo reducen a un número constante de consultas: un único JOIN que carga padres e hijos juntos, o una segunda consulta por lotes que obtiene todas las filas relacionadas con un solo WHERE id IN (...). El JOIN usa un solo viaje de ida y vuelta pero puede duplicar filas padre (y provocar una explosión cartesiana cuando hay varias colecciones); la consulta por lotes usa dos viajes de ida y vuelta pero no devuelve datos duplicados. Cada framework expone ambas estrategias con nombres distintos.
| Framework | Estrategia JOIN (una consulta) | Estrategia de consulta por lotes (WHERE id IN) |
|---|---|---|
| Rails | eager_load(:assoc) | preload(:assoc) |
| Rails (automático) | includes(:assoc) (Rails elige una) | includes(:assoc) |
| Django | select_related("assoc") | prefetch_related("assoc") |
| JPA/Hibernate | JOIN FETCH / @EntityGraph / fetchJoin() de QueryDSL | batch fetching (@BatchSize) |
| Laravel | — | with('assoc') |
| SQL puro | LEFT JOIN | segundo SELECT ... WHERE fk IN (...) |
Los dos métodos de Django son los que más a menudo se confunden, así que conviene ser preciso sobre cuál hace qué. La referencia de la API de QuerySet de Django traza la línea según la aridad de la relación: select_related construye un JOIN y trae las filas relacionadas en la misma sentencia, lo cual solo funciona cuando un padre tiene como máximo un registro relacionado, de modo que cubre ForeignKey y OneToOneField. prefetch_related emite su propia consulta por relación y ensambla los resultados en Python, que es lo que le permite manejar ManyToManyField y claves foráneas inversas.
Rails reparte la misma distinción entre tres métodos. La guía de Active Record Query Interface describe preload como el que dispara una consulta adicional por cada asociación nombrada, y eager_load como el que lo trae todo mediante un único LEFT OUTER JOIN. includes se sitúa entre ambos: la documentación de la API lo describe como el que por defecto usa una consulta separada por asociación y cambia a un join solo cuando las condiciones de la consulta lo obligan. En resumen: preload es siempre una consulta separada, eager_load es siempre un JOIN, e includes deja que ActiveRecord elija.
En Laravel, with() es la solución canónica de eager loading, y emite una consulta por lotes para la relación. Laravel 12.8 añadió Model::automaticallyEagerLoadRelationships(), que aplica eager loading automático a cualquier relación a la que acceda una colección sin una llamada explícita a with().
Por qué FetchType.EAGER no soluciona el N+1
Establecer FetchType.EAGER no soluciona el N+1 con JPQL. El eager fetching cambia cuándo se disparan las consultas adicionales, no si se agrupan por lotes, así que sigues necesitando JOIN FETCH o un @EntityGraph. Este es el malentendido más común sobre JPA. La guía de usuario de Hibernate ORM lo deja claro: una consulta JPQL que deja una asociación eager fuera de su plan de fetch hace que Hibernate ejecute un select de seguimiento por cada asociación eager, lo cual es N+1 con otro nombre, y la propia recomendación de la guía es mapear las asociaciones como lazy y traerlas de forma eager consulta por consulta.
El principio general se mantiene entre ORMs: configurar una relación como eager en el mapeo es una decisión sobre el cuándo, no sobre el agrupamiento por lotes. Un fetch join o un entity graph es lo que realmente carga la asociación en una sola sentencia, colapsando padre e hijos en un único viaje de ida y vuelta.
Cómo detectar consultas N+1
Empieza por leer el SQL que emite tu ORM. El log de desarrollo de Rails imprime todas las consultas; Django expone los recuentos a través de django-debug-toolbar; Hibernate registra las sentencias con spring.jpa.show-sql=true; Laravel las muestra mediante Laravel Debugbar. Los SELECT repetidos y casi idénticos que solo difieren en un id son la firma característica.
Las herramientas de fail-fast convierten el N+1 en un error durante el desarrollo. La gema Bullet avisa sobre asociaciones de Rails no optimizadas (solo en dev/test), nplusone de Python registra las infracciones y, en Laravel, Model::preventLazyLoading() hace ruidoso el acceso diferido: con esta opción activada, una relación resuelta a posteriori lanza LazyLoadingViolationException en lugar de ejecutar silenciosamente otra consulta. Restríngela a entornos que no sean de producción para que una relación olvidada nunca haga fallar una petición en vivo.
El inconveniente: los logs de consultas en desarrollo solo detectan el N+1 en las rutas de código que llegas a ejercitar; un APM en producción detecta aquellas que tu conjunto de datos local era demasiado pequeño para revelar. Los monitores de rendimiento de aplicaciones observan cada consulta en cada petición y trabajo en segundo plano, señalando patrones repetidos junto con el punto exacto de la llamada. Esa es una cobertura que herramientas solo de desarrollo como Bullet o la debug toolbar no pueden ofrecer.
¿Cuándo es aceptable el N+1?
No todo N+1 necesita arreglarse. Cuando N es pequeño y acotado —por ejemplo, una página que siempre renderiza exactamente tres elementos—, las consultas adicionales pueden salir más baratas que el coste de mantenimiento de una cadena de prefetch. Cuando los registros relacionados ya se sirven desde una caché de consultas o de aplicación, las consultas “adicionales” puede que nunca lleguen a la base de datos. Y, ocasionalmente, un bucle explícito con un comentario se lee con más claridad que un eager-load anidado. Estas son las excepciones; trátalas como decisiones deliberadas y documentadas, porque N tiende a crecer con el tiempo incluso cuando estás seguro de que no lo hará.
El patrón es un solo concepto con distintas grafías según el framework, así que apréndelo una vez: detecta la relación a la que se accede dentro de un bucle, elige un JOIN o una consulta por lotes, y monta la detección para que el próximo N+1 falle en tu máquina en lugar de en la de tus usuarios.
Preguntas frecuentes
¿Cuál es la diferencia entre el eager loading basado en JOIN y el eager loading por consulta en lotes?
Una solución basada en JOIN (eager_load en Rails, select_related en Django, JOIN FETCH en JPA, LEFT JOIN en SQL puro) carga padres e hijos en una sola consulta, pero puede duplicar filas padre y provocar una explosión cartesiana cuando hay varias colecciones. Una solución por consulta en lotes (preload en Rails, prefetch_related en Django, with() en Laravel) ejecuta una segunda consulta usando WHERE id IN (...), añadiendo un viaje de ida y vuelta pero sin devolver filas duplicadas. Ambas reducen el N+1 a un número constante de consultas.
¿Establecer FetchType.EAGER soluciona el problema N+1 en Hibernate?
No. Bajo una consulta JPQL, FetchType.EAGER no agrupa por lotes las entidades asociadas; Hibernate emite un SELECT secundario por cada asociación eager que necesita, lo que reproduce el N+1. El eager fetching cambia cuándo se disparan las consultas adicionales, no si se agrupan por lotes. Para cargar realmente una asociación en una sola sentencia necesitas JOIN FETCH, un @EntityGraph o fetchJoin() de QueryDSL. Este comportamiento no ha cambiado hasta Hibernate 7.
¿Por qué los bugs N+1 pasan la revisión de código y las pruebas locales pero fallan en producción?
El N+1 es invisible en el código fuente porque la carga diferida dispara una consulta de forma silenciosa al acceder a la relación dentro de un bucle, sin ninguna palabra clave ni advertencia que lo señale. El número de consultas escala linealmente con N, así que cinco filas sembradas producen unas rápidas seis consultas en desarrollo mientras que cinco mil filas producen cinco mil una en producción. Además, los logs de consultas en desarrollo solo detectan el N+1 en las rutas de código que llegas a ejercitar, y por eso un APM en producción detecta aquellas que tu conjunto de datos local era demasiado pequeño para revelar.
¿Qué método de Django debería usar, select_related o prefetch_related?
Usa select_related para relaciones ForeignKey y OneToOneField; realiza un JOIN de SQL y carga los objetos relacionados en la misma consulta. Usa prefetch_related para ManyToManyField y claves foráneas inversas; ejecuta una búsqueda separada por relación y une los resultados en Python. Elegir el equivocado es el error de N+1 más común en Django: prefetch_related no puede usarse para relaciones directas de valor único de la forma en que está pensado select_related.