//JorgenHoc
← Todos los artículos
EF CorePor Jorge CalderónActualizado 17 min read

EF Core Performance: Resolviendo el Problema de Consultas N+1

Identifica y corrige el problema de consultas N+1 en EF Core usando carga eager, split queries, proyección y logging de consultas. Incluye comparaciones reales de SQL generado.

#entity-framework#dotnet#database#performance

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.

Log de consultas de EF Core: el final de una cascada N+1 con SELECT TOP(1) FROM Customers repetido con los parámetros 498, 499 y 500, seguido de la consulta única con INNER JOIN que produce Include, el par de consultas de AsSplitQuery y la proyección Select con una subconsulta COUNT.
Lo que el log de arriba produce en realidad. La cascada N+1 termina en la orden 500 y luego cada solución se reduce a una o dos instrucciones — con los tiempos por comando que informa EF Core.

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.Id

Eso 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);
EscenarioUsar
Include de una sola colecciónAsSingleQuery (JOIN predeterminado)
Include de múltiples coleccionesAsSplitQuery
Dataset grande, muchas columnasAsSplitQuery
Búsqueda de fila únicaAsSingleQuery

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.CustomerId

Beneficios 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 necesita Include() 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

EstrategiaConsultasFilas TransferidasChange TrackingMejor Para
Lazy loadingN+1Mínimo por consultaNunca (web)
Carga eager (Include)1–3Todas las filas relacionadasOperaciones de escritura, grafo completo necesario
Split query (AsSplitQuery)1 por colecciónFilas dirigidasMúltiples colecciones
Proyección (Select)1Solo columnas seleccionadasNoSolo lectura: APIs, listas, reportes
Carga explícita1 por llamadaFilas dirigidasCargas 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:

EstrategiaInstrucciones SQLÓrdenes cargadas
Consulta por fila (N+1, solo cliente)501500
Consulta por fila (N+1, cliente + líneas)1.001500
Include (eager, consulta única)1500
Include + AsSplitQuery()2500
Proyección Select()1500
💡

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.

Salida de consola con los conteos de instrucciones de EF Core: 501 para consulta por fila sobre una navegación, 1.001 sobre dos, 1 para Include, 2 para Include con AsSplitQuery y 1 para una proyección Select.
La tabla de arriba, directamente desde la consola — misma ejecución, nada transcrito a mano.

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

  1. Habilita query logging en desarrollo — ve cada instrucción SQL
  2. Busca acceso a propiedades de navegación sin Include en bucles y consultas LINQ
  3. Usa proyección Select() para todos los endpoints de solo lectura (controladores que retornan DTOs)
  4. Usa Include() + AsSplitQuery() cuando necesitas grafos de entidad completos con múltiples colecciones
  5. Nunca habilites UseLazyLoadingProxies() en aplicaciones web
  6. Agrega índices en todas las columnas de clave foránea usadas en propiedades de navegación
  7. Escribe aserciones de conteo de consultas en pruebas de integración para prevenir regresiones

Resumen

ProblemaSolución
N+1 en navegación únicaInclude(o => o.Customer)
N+1 en navegación de colecciónInclude(o => o.Lines)
Explosión cartesiana con múltiples colecciones.AsSplitQuery()
Cargando más datos de los necesariosProyección Select()
Datos relacionados condicionalesEntry().Collection().Query().LoadAsync()
N+1 invisible (lazy loading)Deshabilitar UseLazyLoadingProxies(), usar carga explícita
JOINs lentos a pesar de Include correctoAgregar 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.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados