El problema de consultas N+1 es uno de los errores de rendimiento más comunes y dañinos en aplicaciones EF Core. Se oculta durante el desarrollo y solo se manifiesta bajo carga de producción — frecuentemente como endpoints lentos, picos de CPU en la base de datos y timeouts.
¿Qué Es el Problema N+1?
El nombre describe el patrón: se ejecuta 1 consulta para cargar una lista, luego N consultas adicionales — una por fila — para cargar datos relacionados. Con 100 órdenes, son 101 viajes de ida y vuelta a la base de datos. Con 1.000 órdenes, son 1.001.
Ejemplo Concreto Antes/Después
Considera este dominio simple:
public class Order
{
public int Id { get; set; }
public string Reference { get; set; } = "";
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public List<OrderLine> Lines { get; set; } = [];
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public string ProductName { get; set; } = "";
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
}El código roto — parece inocente, funciona en pruebas, destruye producción:
// MAL: patrón de consultas N+1
var orders = await context.Orders.ToListAsync(); // 1 consulta
foreach (var order in orders)
{
// Un viaje de ida y vuelta por orden, en cada iteración
var customer = await context.Customers.FindAsync(order.CustomerId);
var lines = await context.OrderLines
.Where(l => l.OrderId == order.Id)
.ToListAsync();
Console.WriteLine($"{order.Reference} - {customer!.Name}");
foreach (var line in lines)
Console.WriteLine($" {line.ProductName}: {line.Quantity} x {line.UnitPrice:C}");
}Con 500 órdenes, EF Core dispara:
- 1 consulta:
SELECT * FROM Orders - 500 consultas:
SELECT TOP(1) * FROM Customers WHERE Id = @p(una por orden) - 500 consultas:
SELECT * FROM OrderLines WHERE OrderId = @p(una por orden)
Total: 1.001 consultas para renderizar una sola página.
Fíjate en lo que este ejemplo no hace: nunca lee order.Customer ni order.Lines
directamente. Con la configuración por defecto de EF Core eso no dispararía ninguna
consulta — order.Customer sería null y order.Lines una lista vacía, así que
obtendrías una NullReferenceException en lugar de un N+1.
La carga automática al acceder a una propiedad requiere lazy loading, y hay que activarlo
explícitamente: el paquete Microsoft.EntityFrameworkCore.Proxies,
UseLazyLoadingProxies() y propiedades de navegación virtual. Consultar por fila, como
arriba, no necesita nada de eso — y por eso es la causa más habitual en código real.
Detectando N+1 con Query Logging
Antes de poder corregir el problema, necesitas verlo. El método LogTo de EF Core escribe cada instrucción SQL a cualquier destino de salida.
Configuración Mínima en Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.LogTo(
Console.WriteLine,
[DbLoggerCategory.Database.Command.Name],
LogLevel.Information);
options.EnableSensitiveDataLogging(); // muestra valores de parámetros — nunca en producción
});Es tentador envolver esto en if (builder.Environment.IsDevelopment()). Ten cuidado si lo
haces: HostApplicationBuilder lee DOTNET_ENVIRONMENT, no ASPNETCORE_ENVIRONMENT
— este último solo lo honra WebApplicationBuilder. En una aplicación de consola sin
ninguna de las dos definidas, el entorno es Production, la condición es falsa en
silencio, y pasas una tarde preguntándote por qué no se registra nada.
launchSettings.json la define para dotnet run y para los lanzamientos desde el IDE,
pero no forma parte de la aplicación compilada, así que la condición también se apaga en
cuanto alguien ejecuta el ejecutable directamente.
Otro detalle: EF Core registra a través de ILoggerFactory, así que un host con el
proveedor de consola por defecto imprime cada instrucción tanto si llamaste a LogTo como
si no. Eso hace que el logging parezca configurado cuando no lo está. Llama a
builder.Logging.ClearProviders() primero si quieres que la salida sea exactamente la que
pediste, y que no se imprima dos veces.
Usando ILogger en Lugar de Console
options.LogTo(
(eventId, logLevel) => logLevel >= LogLevel.Warning
|| eventId == RelationalEventId.CommandExecuted,
(logEntry) => logger.Log(
logEntry.LogLevel,
logEntry.EventId,
logEntry.ToString()
)
);Detectando N+1 de Forma Programática
Para pruebas de integración o verificaciones en CI, puedes contar las consultas:
public class QueryCounter
{
private int _count;
// Lectura volátil: los incrementos ocurren en el hilo del logger de EF Core.
public int Count => Volatile.Read(ref _count);
public void Increment() => Interlocked.Increment(ref _count);
}
// En la configuración de tu prueba — filtra por event id, no por nivel de log
var counter = new QueryCounter();
options.LogTo(
filter: (eventId, _) => eventId == RelationalEventId.CommandExecuted,
logger: _ => counter.Increment());
// Después de tu acción
Assert.True(counter.Count <= 3, $"Se esperaban ≤3 consultas pero se obtuvieron {counter.Count}");Usa la sobrecarga (filter, logger) y compara RelationalEventId.CommandExecuted de forma
exacta. La sobrecarga más simple LogTo(Action<string>, LogLevel) cuenta todos los
mensajes de log de ese nivel — incluidos eventos de conexión y de transacción — así que el
total queda unas unidades por encima del número real de instrucciones y el umbral de la
aserción se vuelve adivinanza. Requiere
using Microsoft.EntityFrameworkCore.Diagnostics;.
En ASP.NET Core, MiniProfiler te da un overlay en el navegador que lista cada instrucción SQL ejecutada por un request, con el tiempo de cada una. Es la forma más rápida de detectar N+1 en una interfaz web.
Necesitas dos paquetes, no uno: MiniProfiler.AspNetCore.Mvc para el overlay y
MiniProfiler.EntityFrameworkCore para la integración con EF Core, además de
.AddEntityFramework() en el builder. Con solo el primero, el overlay se dibuja pero la
lista de consultas queda vacía — lo que parece exactamente un profiler que no funciona.
Dos detalles más. Pon TrackConnectionOpenClose = false a menos que quieras que cada
instrucción aparezca tres veces: una por la apertura de la conexión, una por el comando y
una por el cierre. Y no esperes que los stack traces te ayuden: con EF Core contienen solo
internos del framework — ExecuteReaderAsync > MoveNext > DispatchEventData y similares —
nunca la línea de tu código que disparó la consulta. Sí son útiles con Dapper o ADO.NET
directo, donde tú mismo invocas el comando.
Una configuración funcionando — los dos paquetes, la configuración de arriba y dos endpoints
para comparar — está en samples/web.

Solución 1: Carga Eager con Include()
Include() le indica a EF Core que haga JOIN de la tabla relacionada en la misma consulta — o que emita una segunda consulta de inmediato — en lugar de cargarla de forma lazy al acceder a ella.
// BIEN: consulta única con JOINs
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Lines)
.ToListAsync();SQL generado (simplificado):
SELECT o.Id, o.Reference, o.CustomerId,
c.Id, c.Name,
ol.Id, ol.OrderId, ol.ProductName, ol.UnitPrice, ol.Quantity
FROM Orders o
INNER JOIN Customers c ON c.Id = o.CustomerId
LEFT JOIN OrderLines ol ON ol.OrderId = o.IdEso es 1 consulta en lugar de 1.001.
ThenInclude() para Jerarquías Profundas
var orders = await context.Orders
.Include(o => o.Customer)
.ThenInclude(c => c.Address) // Customer -> Address
.Include(o => o.Lines)
.ThenInclude(l => l.Product) // OrderLine -> Product
.ThenInclude(p => p.Category) // Product -> Category
.ToListAsync();Include() con múltiples navegaciones de colección produce un producto cartesiano. Si una orden tiene 10 líneas y 5 etiquetas, EF Core las une y obtienes 50 filas por orden en el resultado. Con 1.000 órdenes esto se convierte en millones de filas transferidas desde la base de datos.
Solución 2: AsSplitQuery() para Explosiones Cartesianas
Cuando incluyes múltiples colecciones, usa AsSplitQuery() para indicarle a EF Core que emita consultas separadas en lugar de un JOIN masivo.
var orders = await context.Orders
.Include(o => o.Lines)
.Include(o => o.Tags) // segunda colección = riesgo cartesiano
.AsSplitQuery() // EF Core ejecuta 3 SELECTs dirigidos
.ToListAsync();SQL generado (3 consultas en lugar de 1 JOIN explosivo):
-- Consulta 1: entidad base
SELECT o.Id, o.Reference, o.CustomerId FROM Orders o;
-- Consulta 2: primera colección
SELECT ol.Id, ol.OrderId, ol.ProductName, ol.UnitPrice, ol.Quantity
FROM OrderLines ol
WHERE ol.OrderId IN (1, 2, 3, ...); -- vinculado a los IDs de órdenes cargadas
-- Consulta 3: segunda colección
SELECT t.Id, t.OrderId, t.Name
FROM Tags t
WHERE t.OrderId IN (1, 2, 3, ...);EF Core luego une los resultados en memoria. Esto evita la explosión de filas mientras solo usa 3 consultas en total.
Establecer Split Query como Predeterminado Global
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
});Luego puedes revertir consultas específicas a single query:
var order = await context.Orders
.Include(o => o.Lines)
.AsSingleQuery() // anula el predeterminado global
.FirstOrDefaultAsync(o => o.Id == id);| Escenario | Usar |
|---|---|
| Include de una sola colección | AsSingleQuery (JOIN predeterminado) |
| Include de múltiples colecciones | AsSplitQuery |
| Dataset grande, muchas columnas | AsSplitQuery |
| Búsqueda de fila única | AsSingleQuery |
Solución 3: Proyección con Select() — El Estándar de Oro
Include() carga grafos de entidades completas. La proyección carga solo lo que necesitas. Para casos de uso de solo lectura (respuestas de API, páginas de listas, reportes), la proyección casi siempre es más rápida.
// Solo obtener las columnas que la UI realmente usa
var orderSummaries = await context.Orders
.Select(o => new OrderSummaryDto
{
Id = o.Id,
Reference = o.Reference,
CustomerName = o.Customer.Name, // auto-join, no se necesita Include
LineCount = o.Lines.Count, // subconsulta COUNT
TotalValue = o.Lines.Sum(l => l.UnitPrice * l.Quantity) // subconsulta SUM
})
.ToListAsync();SQL generado:
SELECT o.Id,
o.Reference,
c.Name AS CustomerName,
(SELECT COUNT(*) FROM OrderLines WHERE OrderId = o.Id) AS LineCount,
(SELECT SUM(UnitPrice * Quantity) FROM OrderLines WHERE OrderId = o.Id) AS TotalValue
FROM Orders o
INNER JOIN Customers c ON c.Id = o.CustomerIdBeneficios de la proyección:
- Sin overhead de change tracking — EF Core omite el identity map para tipos anónimos y DTOs que no son entidades
- Payload de red más pequeño — solo las columnas seleccionadas viajan por el cable
- EF Core genera JOINs automáticamente desde propiedades de navegación dentro de
Select()— no se necesitaInclude()explícito - Los agregados (
Count,Sum,Max) delegan el cómputo al motor de base de datos
Expresiones de Proyección Reutilizables
Evita duplicar proyecciones en múltiples consultas extrayéndolas como Expression<Func<T, TResult>>:
public static class OrderProjections
{
// Expresión estática — EF Core puede traducir esto a SQL
public static Expression<Func<Order, OrderSummaryDto>> ToSummary =>
o => new OrderSummaryDto
{
Id = o.Id,
Reference = o.Reference,
CustomerName = o.Customer.Name,
LineCount = o.Lines.Count,
TotalValue = o.Lines.Sum(l => l.UnitPrice * l.Quantity)
};
}
// Uso
var summaries = await context.Orders
.Select(OrderProjections.ToSummary)
.ToListAsync();El método ProjectTo<TDto>(mapper.ConfigurationProvider) de la librería AutoMapper genera el mismo tipo de proyección SQL automáticamente desde tu configuración de mapeo, eliminando la necesidad de escribir Select() manualmente en cada consulta.
Solución 4: Carga Explícita
A veces legítimamente quieres cargar una entidad relacionada solo cuando se cumple una condición — no siempre. La carga explícita te permite tomar esa decisión después de que el padre ya fue cargado.
var order = await context.Orders.FindAsync(orderId);
// Solo cargar líneas si la orden está en un estado que las tiene
if (order?.Status == OrderStatus.Confirmed)
{
// Reference() para propiedades de navegación únicas
await context.Entry(order)
.Reference(o => o.Customer)
.LoadAsync();
// Collection() para propiedades de navegación de colección
await context.Entry(order)
.Collection(o => o.Lines)
.Query() // retorna IQueryable
.Where(l => l.UnitPrice > 0) // filtrar antes de cargar
.LoadAsync();
}La llamada a .Query() te permite filtrar, ordenar o proyectar la colección antes de emitir el SQL — evitando cargar filas que descartarás de inmediato.
Por Qué Lazy Loading Es Peligroso en Aplicaciones Web
EF Core soporta lazy loading a través de proxies (UseLazyLoadingProxies()) o inyección de ILazyLoader. Ambos funcionan disparando automáticamente una consulta en el momento en que accedes a una propiedad de navegación. En una aplicación web, esto es la fábrica de N+1.
// Con lazy loading habilitado, esta acción del controlador oculta 1 + 2N consultas:
public async Task<IActionResult> GetOrders()
{
var orders = await context.Orders.ToListAsync(); // 1 consulta
return Ok(orders.Select(o => new
{
o.Reference,
CustomerName = o.Customer.Name, // la consulta se dispara aquí (oculta)
Lines = o.Lines.Select(l => new // la consulta se dispara aquí (oculta)
{
l.ProductName,
l.Quantity
})
}));
}Las consultas son invisibles en el código del controlador. Se disparan dentro del lambda de Select durante la serialización JSON — después de que tu await terminó, potencialmente fuera del thread pool si se usan serializadores async.
// Los proxies de lazy loading requieren propiedades de navegación virtual
public class Order
{
public virtual Customer Customer { get; set; } = null!; // virtual = el proxy se engancha
public virtual List<OrderLine> Lines { get; set; } = []; // virtual = el proxy se engancha
}Recomendación: No habilites lazy loading en aplicaciones ASP.NET Core. Usa carga eager o proyección para lecturas. Usa carga explícita para cargas condicionales.
Comparación de Rendimiento
| Estrategia | Consultas | Filas Transferidas | Change Tracking | Mejor Para |
|---|---|---|---|---|
| Lazy loading | N+1 | Mínimo por consulta | Sí | Nunca (web) |
Carga eager (Include) | 1–3 | Todas las filas relacionadas | Sí | Operaciones de escritura, grafo completo necesario |
Split query (AsSplitQuery) | 1 por colección | Filas dirigidas | Sí | Múltiples colecciones |
Proyección (Select) | 1 | Solo columnas seleccionadas | No | Solo lectura: APIs, listas, reportes |
| Carga explícita | 1 por llamada | Filas dirigidas | Sí | Cargas condicionales |
Índices de Base de Datos en Claves Foráneas
EF Core crea automáticamente índices en claves primarias. No crea automáticamente índices en columnas de clave foránea. Sin ellos, cada Include() se convierte en un full table scan en la tabla relacionada.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OrderLine>(entity =>
{
// Índice de clave foránea — crítico para el rendimiento de Include()
entity.HasIndex(l => l.OrderId)
.HasDatabaseName("IX_OrderLines_OrderId");
// Índice compuesto para consultas filtradas
entity.HasIndex(l => new { l.OrderId, l.UnitPrice })
.HasDatabaseName("IX_OrderLines_OrderId_UnitPrice");
});
modelBuilder.Entity<Order>(entity =>
{
entity.HasIndex(o => o.CustomerId)
.HasDatabaseName("IX_Orders_CustomerId");
// Índice cubriente para la proyección de resumen
entity.HasIndex(o => o.CustomerId)
.IncludeProperties(o => new { o.Reference, o.Status })
.HasDatabaseName("IX_Orders_CustomerId_Covering");
});
}Ejecuta dotnet ef [migrations](/blog/ef-core-migrations-walkthrough) add AddForeignKeyIndexes después de agregar índices mediante Fluent API. Las migraciones de EF Core generarán el SQL CREATE INDEX correcto. Sin una migración, la configuración de OnModelCreating solo existe en el modelo — no en la base de datos real.
Midiendo la Diferencia con Tus Propios Datos
Cuánto ganas depende por completo de tu número de filas, de tu latencia de ida y vuelta a la base de datos y de cuántas propiedades de navegación toques — así que un tiempo medido en la máquina de otra persona te dice muy poco. Mídelo contra tu propia carga de trabajo:
// Registra cada instrucción que EF Core envía, y luego cuéntalas
builder.Logging.ClearProviders(); // si no, el proveedor de consola del host las imprime también
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name],
LogLevel.Information);
});Llama a un endpoint de lista una vez con lazy loading y otra con proyección, y compara dos cosas: el número de instrucciones SQL en el log y el tiempo de reloj.
El conteo de instrucciones es la señal honesta. Lazy loading sobre N filas padre emite 1 + N consultas — o 1 + 2N cuando tocas dos propiedades de navegación — mientras que la proyección emite exactamente una. Esa proporción es determinista y puedes confirmarla en el log en menos de un minuto.
Conteos de Instrucciones Medidos
Ejecutando cada estrategia contra 500 órdenes con 8 líneas cada una, contando los comandos que EF Core ejecutó realmente:
| Estrategia | Instrucciones SQL | Órdenes cargadas |
|---|---|---|
| Consulta por fila (N+1, solo cliente) | 501 | 500 |
| Consulta por fila (N+1, cliente + líneas) | 1.001 | 500 |
Include (eager, consulta única) | 1 | 500 |
Include + AsSplitQuery() | 2 | 500 |
Proyección Select() | 1 | 500 |
Son conteos, no tiempos, y es deliberado — son reproducibles en cualquier máquina y con cualquier proveedor relacional, así que puedes verificarlos en lugar de creerlos. Medidos con EF Core 10 contra SQL Server LocalDB.
El programa que los produjo es
samples/ef-core-n-plus-one. Ejecuta primero
seed.sql contra una base de datos vacía — crea el esquema que se usa en todo este
artículo y carga las 500 órdenes, así que tus conteos deberían coincidir exactamente con
estos.

Fíjate en que AsSplitQuery() emite dos instrucciones y no tres: Customer es una
navegación de referencia, así que se queda en el JOIN, y solo se separa la colección
Lines. Añade una segunda colección y pasan a ser tres.
Dónde aterriza la diferencia de tiempo es otra cuestión, y está dominada por la latencia de ida y vuelta. El mismo N+1 que cuesta unos pocos milisegundos contra una instancia local puede costar segundos contra una base de datos gestionada en otra región, porque pagas el viaje de ida y vuelta N veces en lugar de una. Por eso los problemas de N+1 pasan tan a menudo las pruebas locales y aparecen en producción.
Lista de Verificación: Eliminando N+1 en una Aplicación Real
- Habilita query logging en desarrollo — ve cada instrucción SQL
- Busca acceso a propiedades de navegación sin
Includeen bucles y consultas LINQ - Usa proyección
Select()para todos los endpoints de solo lectura (controladores que retornan DTOs) - Usa
Include()+AsSplitQuery()cuando necesitas grafos de entidad completos con múltiples colecciones - Nunca habilites
UseLazyLoadingProxies()en aplicaciones web - Agrega índices en todas las columnas de clave foránea usadas en propiedades de navegación
- Escribe aserciones de conteo de consultas en pruebas de integración para prevenir regresiones
Resumen
| Problema | Solución |
|---|---|
| N+1 en navegación única | Include(o => o.Customer) |
| N+1 en navegación de colección | Include(o => o.Lines) |
| Explosión cartesiana con múltiples colecciones | .AsSplitQuery() |
| Cargando más datos de los necesarios | Proyección Select() |
| Datos relacionados condicionales | Entry().Collection().Query().LoadAsync() |
| N+1 invisible (lazy loading) | Deshabilitar UseLazyLoadingProxies(), usar carga explícita |
| JOINs lentos a pesar de Include correcto | Agregar HasIndex() en columnas de clave foránea |
El cambio de mayor impacto en la mayoría de las aplicaciones EF Core es reemplazar ToList() + acceso a propiedades de navegación con una proyección Select() que obtiene solo las columnas necesarias. Haz eso primero, mide, luego ajusta con split queries e índices.