//JorgenHoc
← Todos os artigos
EF CorePor Jorge CalderónAtualizado 17 min read

EF Core Performance: Resolvendo o Problema de Consultas N+1

Identifique e corrija o problema de consultas N+1 no EF Core usando eager loading, split queries, projeção e logging de consultas. Inclui comparações reais de SQL gerado.

#entity-framework#dotnet#database#performance

O problema de consultas N+1 é um dos erros de performance mais comuns e prejudiciais em aplicações EF Core. Ele se esconde durante o desenvolvimento e só se manifesta sob carga de produção — frequentemente como endpoints lentos, picos de CPU no banco de dados e timeouts.

O Que É o Problema N+1?

O nome descreve o padrão: você executa 1 consulta para carregar uma lista, depois N consultas adicionais — uma por linha — para carregar dados relacionados. Com 100 pedidos, são 101 viagens de ida e volta ao banco de dados. Com 1.000 pedidos, são 1.001.

Exemplo Concreto Antes/Depois

Considere este domínio simples:

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; }
}

O código quebrado — parece inocente, funciona nos testes, destrói a produção:

// RUIM: padrão de consultas N+1
var orders = await context.Orders.ToListAsync();          // 1 consulta
 
foreach (var order in orders)
{
    // Uma viagem de ida e volta por pedido, em cada iteração
    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}");
}

Com 500 pedidos, o EF Core dispara:

  • 1 consulta: SELECT * FROM Orders
  • 500 consultas: SELECT TOP(1) * FROM Customers WHERE Id = @p (uma por pedido)
  • 500 consultas: SELECT * FROM OrderLines WHERE OrderId = @p (uma por pedido)

Total: 1.001 consultas para renderizar uma única página.

⚠️

Observe o que este exemplo não faz: ele nunca lê order.Customer nem order.Lines diretamente. Com a configuração padrão do EF Core isso não dispararia consulta alguma — order.Customer seria null e order.Lines uma lista vazia, então você teria uma NullReferenceException em vez de um N+1.

O carregamento automático ao acessar uma propriedade exige lazy loading, que precisa ser habilitado explicitamente: o pacote Microsoft.EntityFrameworkCore.Proxies, UseLazyLoadingProxies() e propriedades de navegação virtual. Consultar por linha, como acima, não precisa de nada disso — e é justamente por isso que é a causa mais comum em código real.


Detectando N+1 com Query Logging

Antes de corrigir o problema, você precisa enxergá-lo. O método LogTo do EF Core escreve cada instrução SQL para qualquer destino de saída.

Configuração Mínima no Program.cs

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
 
    options.LogTo(
        Console.WriteLine,
        [DbLoggerCategory.Database.Command.Name],
        LogLevel.Information);
 
    options.EnableSensitiveDataLogging(); // exibe valores dos parâmetros — nunca em produção
});
⚠️

É tentador envolver isso em if (builder.Environment.IsDevelopment()). Cuidado se fizer: o HostApplicationBuilderDOTNET_ENVIRONMENT, não ASPNETCORE_ENVIRONMENT — este último só é honrado pelo WebApplicationBuilder. Em uma aplicação de console sem nenhuma das duas definidas, o ambiente é Production, a condição é falsa silenciosamente, e você passa uma tarde se perguntando por que nada é registrado.

O launchSettings.json a define para dotnet run e para execuções pela IDE, mas não faz parte da aplicação compilada — então a condição também desliga no momento em que alguém executa o binário diretamente.

Outro detalhe: o EF Core registra através do ILoggerFactory, então um host com o provedor de console padrão imprime cada instrução independentemente de você ter chamado LogTo. Isso faz o logging parecer configurado quando não está. Chame builder.Logging.ClearProviders() primeiro se quiser que a saída seja exatamente a que você pediu, e não impressa duas vezes.

Usando ILogger em Vez do 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 testes de integração ou verificações em CI, você pode contar as consultas:

public class QueryCounter
{
    private int _count;
 
    // Leitura volátil: os incrementos ocorrem na thread do logger do EF Core.
    public int Count => Volatile.Read(ref _count);
 
    public void Increment() => Interlocked.Increment(ref _count);
}
 
// Na configuração do seu teste — filtre pelo event id, não pelo nível de log
var counter = new QueryCounter();
 
options.LogTo(
    filter: (eventId, _) => eventId == RelationalEventId.CommandExecuted,
    logger: _ => counter.Increment());
 
// Após a sua ação
Assert.True(counter.Count <= 3, $"Esperadas ≤3 consultas mas obtidas {counter.Count}");
💡

Use a sobrecarga (filter, logger) e compare RelationalEventId.CommandExecuted de forma exata. A sobrecarga mais simples LogTo(Action<string>, LogLevel) conta todas as mensagens de log daquele nível — incluindo eventos de conexão e de transação — então o total fica algumas unidades acima da contagem real de instruções e o limite da asserção vira chute. Requer using Microsoft.EntityFrameworkCore.Diagnostics;.

💡

No ASP.NET Core, o MiniProfiler dá a você um overlay no navegador que lista cada instrução SQL executada por um request, com o tempo de cada uma. É a forma mais rápida de identificar N+1 em uma interface web.

Você precisa de dois pacotes, não um: MiniProfiler.AspNetCore.Mvc para o overlay e MiniProfiler.EntityFrameworkCore para a integração com o EF Core, mais .AddEntityFramework() no builder. Só com o primeiro, o overlay aparece mas a lista de consultas fica vazia — o que parece exatamente um profiler que não funciona.

Dois detalhes a mais. Defina TrackConnectionOpenClose = false a menos que você queira que cada instrução apareça três vezes: uma pela abertura da conexão, uma pelo comando e uma pelo fechamento. E não espere que os stack traces ajudem: com o EF Core eles contêm apenas internos do framework — ExecuteReaderAsync > MoveNext > DispatchEventData e afins — nunca a linha do seu código que disparou a consulta. Eles são realmente úteis com Dapper ou ADO.NET direto, onde você mesmo invoca o comando.

Uma configuração funcionando — os dois pacotes, a configuração acima e dois endpoints para comparar — está em samples/web.

Log de consultas do EF Core: o final de uma cascata N+1 com SELECT TOP(1) FROM Customers repetido com os parâmetros 498, 499 e 500, seguido da consulta única com INNER JOIN produzida pelo Include, o par de consultas do AsSplitQuery e a projeção Select com uma subconsulta COUNT.
O que o log acima realmente produz. A cascata N+1 termina no pedido 500 e depois cada solução se reduz a uma ou duas instruções — com os tempos por comando informados pelo próprio EF Core.

Solução 1: Eager Loading com Include()

Include() instrui o EF Core a fazer JOIN da tabela relacionada na mesma consulta — ou emitir uma segunda consulta imediatamente — em vez de fazer lazy-loading no acesso.

// BOM: consulta única com JOINs
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
    .ToListAsync();

SQL gerado (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

Isso é 1 consulta em vez de 1.001.

ThenInclude() para Hierarquias 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() com múltiplas navegações de coleção produz um produto cartesiano. Se um pedido tem 10 linhas e 5 tags, o EF Core as une e você obtém 50 linhas por pedido no resultado. Com 1.000 pedidos isso se torna milhões de linhas transferidas do banco de dados.


Solução 2: AsSplitQuery() para Explosões Cartesianas

Quando você inclui múltiplas coleções, use AsSplitQuery() para indicar ao EF Core que emita consultas separadas em vez de um JOIN massivo.

var orders = await context.Orders
    .Include(o => o.Lines)
    .Include(o => o.Tags)          // segunda coleção = risco cartesiano
    .AsSplitQuery()                // EF Core executa 3 SELECTs direcionados
    .ToListAsync();

SQL gerado (3 consultas em vez de 1 JOIN explosivo):

-- Consulta 1: entidade base
SELECT o.Id, o.Reference, o.CustomerId FROM Orders o;
 
-- Consulta 2: primeira coleção
SELECT ol.Id, ol.OrderId, ol.ProductName, ol.UnitPrice, ol.Quantity
FROM OrderLines ol
WHERE ol.OrderId IN (1, 2, 3, ...);  -- vinculado aos IDs dos pedidos carregados
 
-- Consulta 3: segunda coleção
SELECT t.Id, t.OrderId, t.Name
FROM Tags t
WHERE t.OrderId IN (1, 2, 3, ...);

O EF Core então costura os resultados em memória. Isso evita a explosão de linhas enquanto usa apenas 3 consultas no total.

Definindo Split Query como Padrão Global

options.UseSqlServer(connectionString, sqlOptions =>
{
    sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
});

Você pode então reverter consultas específicas para single query:

var order = await context.Orders
    .Include(o => o.Lines)
    .AsSingleQuery()   // sobrescreve o padrão global
    .FirstOrDefaultAsync(o => o.Id == id);
CenárioUsar
Include de coleção únicaAsSingleQuery (JOIN padrão)
Include de múltiplas coleçõesAsSplitQuery
Dataset grande, muitas colunasAsSplitQuery
Busca de linha únicaAsSingleQuery

Solução 3: Projeção com Select() — O Padrão Ouro

Include() carrega grafos de entidades completos. A projeção carrega apenas o que você precisa. Para casos de uso somente leitura (respostas de API, páginas de lista, relatórios), a projeção é quase sempre mais rápida.

// Buscar apenas as colunas que a UI realmente usa
var orderSummaries = await context.Orders
    .Select(o => new OrderSummaryDto
    {
        Id = o.Id,
        Reference = o.Reference,
        CustomerName = o.Customer.Name,          // auto-join, sem necessidade de Include
        LineCount = o.Lines.Count,               // subconsulta COUNT
        TotalValue = o.Lines.Sum(l => l.UnitPrice * l.Quantity)  // subconsulta SUM
    })
    .ToListAsync();

SQL gerado:

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

Benefícios da projeção:

  • Sem overhead de change tracking — o EF Core ignora o identity map para tipos anônimos e DTOs que não são entidades
  • Payload de rede menor — apenas as colunas selecionadas trafegam pelo cabo
  • O EF Core gera JOINs automaticamente a partir de propriedades de navegação dentro de Select() — sem necessidade de Include() explícito
  • Agregações (Count, Sum, Max) empurram o cálculo para o motor do banco de dados

Expressões de Projeção Reutilizáveis

Evite duplicar projeções em múltiplas consultas extraindo-as como Expression<Func<T, TResult>>:

public static class OrderProjections
{
    // Expressão estática — EF Core pode traduzir isso para 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();
💡

O método ProjectTo<TDto>(mapper.ConfigurationProvider) da biblioteca AutoMapper gera o mesmo tipo de projeção SQL automaticamente a partir da sua configuração de mapeamento, eliminando a necessidade de escrever Select() manualmente em cada consulta.


Solução 4: Carregamento Explícito

Às vezes você legitimamente quer carregar uma entidade relacionada apenas quando uma condição é satisfeita — não sempre. O carregamento explícito permite tomar essa decisão depois que o pai já foi carregado.

var order = await context.Orders.FindAsync(orderId);
 
// Carregar linhas apenas se o pedido estiver em um estado que as possui
if (order?.Status == OrderStatus.Confirmed)
{
    // Reference() para propriedades de navegação únicas
    await context.Entry(order)
        .Reference(o => o.Customer)
        .LoadAsync();
 
    // Collection() para propriedades de navegação de coleção
    await context.Entry(order)
        .Collection(o => o.Lines)
        .Query()                                          // retorna IQueryable
        .Where(l => l.UnitPrice > 0)                    // filtrar antes de carregar
        .LoadAsync();
}

A chamada a .Query() permite filtrar, ordenar ou projetar a coleção antes de emitir o SQL — evitando carregar linhas que você descartaria imediatamente.


Por Que Lazy Loading É Perigoso em Aplicações Web

O EF Core suporta lazy loading via proxies (UseLazyLoadingProxies()) ou injeção de ILazyLoader. Ambos funcionam disparando automaticamente uma consulta no momento em que você acessa uma propriedade de navegação. Em uma aplicação web, isso é a fábrica de N+1.

// Com lazy loading habilitado, esta action do controller 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,  // consulta dispara aqui (oculta)
        Lines = o.Lines.Select(l => new  // consulta dispara aqui (oculta)
        {
            l.ProductName,
            l.Quantity
        })
    }));
}

As consultas são invisíveis no código do controller. Elas disparam dentro do lambda do Select durante a serialização JSON — depois que seu await terminou, potencialmente fora do thread pool se estiver usando serializadores async.

// Proxies de lazy loading exigem propriedades de navegação virtual
public class Order
{
    public virtual Customer Customer { get; set; } = null!;  // virtual = o proxy se conecta
    public virtual List<OrderLine> Lines { get; set; } = []; // virtual = o proxy se conecta
}

Recomendação: Não habilite lazy loading em aplicações ASP.NET Core. Use eager loading ou projeção para leituras. Use carregamento explícito para cargas condicionais.


Comparação de Performance

EstratégiaConsultasLinhas TransferidasChange TrackingMelhor Para
Lazy loadingN+1Mínimo por consultaSimNunca (web)
Eager loading (Include)1–3Todas as linhas relacionadasSimOperações de escrita, grafo completo necessário
Split query (AsSplitQuery)1 por coleçãoLinhas direcionadasSimMúltiplas coleções
Projeção (Select)1Apenas colunas selecionadasNãoSomente leitura: APIs, listas, relatórios
Carregamento explícito1 por chamadaLinhas direcionadasSimCargas condicionais

Índices de Banco de Dados em Chaves Estrangeiras

O EF Core cria índices automaticamente em chaves primárias. Ele não cria automaticamente índices em colunas de chave estrangeira. Sem eles, cada Include() se torna um full table scan na tabela relacionada.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<OrderLine>(entity =>
    {
        // Índice de chave estrangeira — crítico para a performance do Include()
        entity.HasIndex(l => l.OrderId)
              .HasDatabaseName("IX_OrderLines_OrderId");
 
        // Índice composto 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 cobrindo para a projeção de resumo
        entity.HasIndex(o => o.CustomerId)
              .IncludeProperties(o => new { o.Reference, o.Status })
              .HasDatabaseName("IX_Orders_CustomerId_Covering");
    });
}
⚠️

Execute dotnet ef [migrations](/blog/ef-core-migrations-walkthrough) add AddForeignKeyIndexes após adicionar índices via Fluent API. As migrations do EF Core vão gerar o SQL CREATE INDEX correto. Sem uma migration, a configuração do OnModelCreating existe apenas no modelo — não no banco de dados real.


Medindo a Diferença com Seus Próprios Dados

O tamanho do ganho depende inteiramente da sua contagem de linhas, da sua latência de ida e volta até o banco de dados e de quantas propriedades de navegação você toca — ou seja, um tempo medido na máquina de outra pessoa diz muito pouco. Meça contra a sua própria carga de trabalho:

// Registre cada instrução que o EF Core envia e depois conte-as
builder.Logging.ClearProviders(); // senão o provedor de console do host também as imprime
 
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name],
                  LogLevel.Information);
});

Chame um endpoint de lista uma vez com lazy loading e outra com projeção, e compare duas coisas: o número de instruções SQL no log e o tempo de relógio.

A contagem de instruções é o sinal honesto. Lazy loading sobre N linhas pai emite 1 + N consultas — ou 1 + 2N quando você toca duas propriedades de navegação — enquanto a projeção emite exatamente uma. Essa proporção é determinística e você pode confirmá-la no log em menos de um minuto.

Contagens de Instruções Medidas

Executando cada estratégia contra 500 pedidos com 8 linhas cada, contando os comandos que o EF Core realmente executou:

EstratégiaInstruções SQLPedidos carregados
Consulta por linha (N+1, apenas cliente)501500
Consulta por linha (N+1, cliente + linhas)1.001500
Include (eager, consulta única)1500
Include + AsSplitQuery()2500
Projeção Select()1500
💡

São contagens, não tempos, e isso é deliberado — são reproduzíveis em qualquer máquina e com qualquer provedor relacional, então você pode verificá-las em vez de acreditar nelas. Medidas com EF Core 10 contra SQL Server LocalDB.

O programa que as produziu é samples/ef-core-n-plus-one. Execute primeiro seed.sql contra um banco de dados vazio — ele cria o esquema usado ao longo deste artigo e carrega os 500 pedidos, então suas contagens devem coincidir exatamente com estas.

Saída de console com as contagens de instruções do EF Core: 501 para consulta por linha sobre uma navegação, 1.001 sobre duas, 1 para Include, 2 para Include com AsSplitQuery e 1 para uma projeção Select.
A tabela acima, direto do console — mesma execução, nada redigitado.

Note que AsSplitQuery() emite duas instruções e não três: Customer é uma navegação de referência, então permanece no JOIN, e apenas a coleção Lines é separada. Adicione uma segunda coleção e passam a ser três.

Onde a diferença de tempo vai parar é outra questão, e ela é dominada pela latência de ida e volta. O mesmo N+1 que custa poucos milissegundos contra uma instância local pode custar segundos contra um banco gerenciado em outra região, porque você paga a viagem de ida e volta N vezes em vez de uma. É por isso que problemas de N+1 tão frequentemente passam nos testes locais e só aparecem em produção.


Checklist: Eliminando N+1 em uma Aplicação Real

  1. Habilite query logging no desenvolvimento — veja cada instrução SQL
  2. Busque acesso a propriedades de navegação sem Include em loops e consultas LINQ
  3. Use projeção Select() para todos os endpoints somente leitura (controllers que retornam DTOs)
  4. Use Include() + AsSplitQuery() quando precisar de grafos de entidade completos com múltiplas coleções
  5. Nunca habilite UseLazyLoadingProxies() em aplicações web
  6. Adicione índices em todas as colunas de chave estrangeira usadas em propriedades de navegação
  7. Escreva asserções de contagem de consultas em testes de integração para prevenir regressões

Resumo

ProblemaSolução
N+1 em navegação únicaInclude(o => o.Customer)
N+1 em navegação de coleçãoInclude(o => o.Lines)
Explosão cartesiana com múltiplas coleções.AsSplitQuery()
Carregando mais dados do que o necessárioProjeção Select()
Dados relacionados condicionaisEntry().Collection().Query().LoadAsync()
N+1 invisível (lazy loading)Desabilitar UseLazyLoadingProxies(), usar carregamento explícito
JOINs lentos apesar do Include corretoAdicionar HasIndex() nas colunas de chave estrangeira

A mudança de maior impacto na maioria das aplicações EF Core é substituir ToList() + acesso a propriedades de navegação por uma projeção Select() que busca apenas as colunas necessárias. Faça isso primeiro, meça, depois ajuste com split queries e índices.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados