En el artículo anterior vimos cómo EF Core puede traer más datos de los necesarios sin avisar: un ToList() prematuro que consulta toda la tabla, o un Select con expresiones no traducibles que termina haciendo SELECT * en silencio.
Este problema es diferente. No es que una query traiga demasiado, es que una operación que debería ser una sola query termina convirtiéndose en cientos o miles.
Y lo más difícil: en desarrollo nadie lo nota.
Por qué es el más traicionero de los tres
El ToList() prematuro y el SELECT * silencioso tienen algo en común: el daño es proporcional al tamaño de la tabla. Con pocos datos, el impacto es menor, pero el problema ya existe desde el primer día.
El N+1 es diferente.
Con 10 pedidos en la base de datos, hace 11 queries. Nadie lo siente. Con 10,000 pedidos, hace 10,001 queries por cada carga de pantalla. La base de datos empieza a ahogarse, los tiempos se disparan y el equipo no entiende qué cambió — porque el código lleva meses igual.
Es un problema que crece silenciosamente junto con el negocio.
Y el verdadero costo no es solo “más SQL”. Cada query implica:
- un roundtrip a la base de datos
- parsing y compilación del comando
- ejecución
- transferencia de datos
- sincronización de conexiones
- presión sobre el connection pool
Mil queries pequeñas suelen ser muchísimo más costosas que una sola query grande.
Cómo ocurre en EF Core
Si vienes de EF6 o de otros ORMs con lazy loading habilitado por default, el N+1 clásico era acceder a una propiedad de navegación dentro de un loop y que el ORM disparara una query automáticamente por debajo, sin que el código lo hiciera explícito.
En EF Core el lazy loading está deshabilitado por default. Si intentas acceder a una propiedad de navegación que no fue cargada, obtienes null, no una query silenciosa.
Eso es mejor, pero el problema sigue ocurriendo — ahora de forma explícita.
El N+1 en EF Core se ve así:
var pedidos = await context.Pedidos
.Where(p => p.FechaCreacion >= hace30Dias)
.ToListAsync(); // Query 1: trae los pedidos
foreach (var pedido in pedidos)
{
// Query 2..N: una por cada pedido
var cliente = await context.Clientes
.FirstOrDefaultAsync(c => c.Id == pedido.ClienteId);
Console.WriteLine($"{cliente.Nombre} - {pedido.Total}");
}
El SQL que se ejecuta:
-- Query 1
SELECT p.Id, p.ClienteId, p.Total, p.FechaCreacion
FROM Pedidos
WHERE p.FechaCreacion >= '2025-02-14'
-- Query 2
SELECT TOP(1) * FROM Clientes WHERE Id = 1
-- Query 3
SELECT TOP(1) * FROM Clientes WHERE Id = 2
-- ... y así por cada pedido en el resultado
Con 500 pedidos en el rango de fechas: 501 queries.
Con 5,000 pedidos: 5,001.
La variante que se escapa en code review
El ejemplo anterior es fácil de detectar porque la query adicional es visible dentro del loop.
Pero en proyectos reales el problema suele esconderse detrás de servicios aparentemente inocentes:
var pedidos = await context.Pedidos
.Where(p => p.FechaCreacion >= hace30Dias)
.ToListAsync();
foreach (var pedido in pedidos)
{
// Parece una llamada inocente a un servicio
var dto = await _pedidoService.EnriquecerAsync(pedido);
resultado.Add(dto);
}
Y dentro del servicio:
public async Task<PedidoDto> EnriquecerAsync(Pedido pedido)
{
var cliente = await context.Clientes
.FirstOrDefaultAsync(c => c.Id == pedido.ClienteId);
var ultimaFactura = await context.Facturas
.Where(f => f.ClienteId == pedido.ClienteId)
.OrderByDescending(f => f.FechaEmision)
.FirstOrDefaultAsync();
return new PedidoDto
{
Cliente = cliente.Nombre,
Total = pedido.Total,
UltimaFactura = ultimaFactura?.Folio
};
}
Aquí el loop ejecuta 2 queries por pedido en lugar de 1.
Con 500 pedidos: 1,001 queries.
Y el problema es mucho más difícil de detectar porque el foreach se ve completamente razonable. El costo real está escondido en otro archivo, detrás de capas de abstracción.
Este tipo de N+1 suele sobrevivir code reviews durante meses.
Cómo detectarlo
La misma herramienta del artículo anterior: los logs de EF Core.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information));
Con el N+1 activo, verás algo así en la consola:
Executed DbCommand (2ms) SELECT ... FROM Pedidos WHERE ...
Executed DbCommand (1ms) SELECT TOP(1) ... FROM Clientes WHERE Id = 1
Executed DbCommand (1ms) SELECT TOP(1) ... FROM Clientes WHERE Id = 2
Executed DbCommand (1ms) SELECT TOP(1) ... FROM Clientes WHERE Id = 3
Executed DbCommand (1ms) SELECT TOP(1) ... FROM Clientes WHERE Id = 4
...
Si ves el mismo patrón de query repitiéndose con diferentes parámetros, tienes un N+1.
Una señal todavía más rápida:
si el número de queries crece proporcionalmente al número de registros que devuelve la primera query, casi siempre es N+1.
Las soluciones
Include — cuando necesitas la entidad completa
var pedidos = await context.Pedidos
.Include(p => p.Cliente)
.Where(p => p.FechaCreacion >= hace30Dias)
.ToListAsync();
foreach (var pedido in pedidos)
{
// Cliente ya está cargado, sin queries adicionales
Console.WriteLine($"{pedido.Cliente.Nombre} - {pedido.Total}");
}
EF Core genera un JOIN y trae todo en una sola query:
SELECT p.Id, p.Total, p.FechaCreacion, c.Id, c.Nombre, c.RFC ...
FROM Pedidos p
LEFT JOIN Clientes c ON p.ClienteId = c.Id
WHERE p.FechaCreacion >= '2025-02-14'
Esto resuelve el N+1 porque elimina las queries repetidas.
Funciona bien cuando realmente necesitas la entidad Cliente completa. Si solo necesitas el nombre, estás trayendo columnas de más.
Y cuidado: usar múltiples Include sobre colecciones puede llevar a otro problema distinto — explosión cartesiana. Lo veremos en el siguiente artículo.
Proyección con Select — la más eficiente para DTOs
var pedidos = await context.Pedidos
.Where(p => p.FechaCreacion >= hace30Dias)
.Select(p => new PedidoResumenDto
{
Cliente = p.Cliente.Nombre,
Total = p.Total
})
.ToListAsync();
EF Core resuelve el JOIN automáticamente a partir de la proyección, sin necesidad de Include.
El SQL resultante solo trae las columnas necesarias:
SELECT c.Nombre, p.Total
FROM Pedidos p
LEFT JOIN Clientes c ON p.ClienteId = c.Id
WHERE p.FechaCreacion >= '2025-02-14'
Esta suele ser la mejor solución cuando trabajas con DTOs:
- una sola query
- solo las columnas necesarias
- sin entidades adicionales en memoria
Cuando el enriquecimiento es inevitable
A veces el servicio que enriquece los datos tiene lógica compleja que no puedes mover fácilmente a una proyección SQL.
En ese caso, la alternativa es cargar todo lo necesario antes del loop:
var pedidos = await context.Pedidos
.Where(p => p.FechaCreacion >= hace30Dias)
.ToListAsync();
var clienteIds = pedidos
.Select(p => p.ClienteId)
.Distinct()
.ToList();
// Una sola query para todos los clientes necesarios
var clientes = await context.Clientes
.Where(c => clienteIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
foreach (var pedido in pedidos)
{
var cliente = clientes[pedido.ClienteId];
Console.WriteLine($"{cliente.Nombre} - {pedido.Total}");
}
Ahora el número de queries es constante:
- 1 query para pedidos
- 1 query para clientes
No importa si tienes 10 pedidos o 100,000.
En lugar de N queries de un registro cada una, ahora son 2 queries en total.
Resumen
| Situación | Queries ejecutadas | Solución |
|---|---|---|
| Query en loop, entidad relacionada | 1 + N |
Include o proyección con Select
|
| Servicio con queries dentro del loop | 1 + (N × M) | Cargar datos necesarios antes del loop |
Proyección con Select y JOIN |
1 | ✅ Ya es correcto |
La regla general:
Todo lo que necesitas para procesar una lista debería estar cargado antes de iterar sobre ella.
¿Qué sigue?
Hasta ahora vimos tres problemas distintos:
- traer más registros de los necesarios
- traer más columnas de las necesarias
- multiplicar queries accidentalmente
El siguiente problema es el opuesto al N+1:
hacer una sola query… pero generar muchas más filas de las esperadas.
Cuando abusas de Include, EF Core puede terminar produciendo joins gigantescos que duplican datos masivamente — la explosión cartesiana.
Y sí, EF Core incluso tiene herramientas como AsSplitQuery() para intentar balancear ambos extremos.
Lo veremos en el siguiente artículo.
¿Has encontrado un N+1 en producción? ¿Cómo lo detectaste? Cuéntame en los comentarios.
Top comments (0)