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 HostApplicationBuilder lê DOTNET_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.

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.IdIsso é 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ário | Usar |
|---|---|
| Include de coleção única | AsSingleQuery (JOIN padrão) |
| Include de múltiplas coleções | AsSplitQuery |
| Dataset grande, muitas colunas | AsSplitQuery |
| Busca de linha única | AsSingleQuery |
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.CustomerIdBenefí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 deInclude()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égia | Consultas | Linhas Transferidas | Change Tracking | Melhor Para |
|---|---|---|---|---|
| Lazy loading | N+1 | Mínimo por consulta | Sim | Nunca (web) |
Eager loading (Include) | 1–3 | Todas as linhas relacionadas | Sim | Operações de escrita, grafo completo necessário |
Split query (AsSplitQuery) | 1 por coleção | Linhas direcionadas | Sim | Múltiplas coleções |
Projeção (Select) | 1 | Apenas colunas selecionadas | Não | Somente leitura: APIs, listas, relatórios |
| Carregamento explícito | 1 por chamada | Linhas direcionadas | Sim | Cargas 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égia | Instruções SQL | Pedidos carregados |
|---|---|---|
| Consulta por linha (N+1, apenas cliente) | 501 | 500 |
| Consulta por linha (N+1, cliente + linhas) | 1.001 | 500 |
Include (eager, consulta única) | 1 | 500 |
Include + AsSplitQuery() | 2 | 500 |
Projeção Select() | 1 | 500 |
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.

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
- Habilite query logging no desenvolvimento — veja cada instrução SQL
- Busque acesso a propriedades de navegação sem
Includeem loops e consultas LINQ - Use projeção
Select()para todos os endpoints somente leitura (controllers que retornam DTOs) - Use
Include()+AsSplitQuery()quando precisar de grafos de entidade completos com múltiplas coleções - Nunca habilite
UseLazyLoadingProxies()em aplicações web - Adicione índices em todas as colunas de chave estrangeira usadas em propriedades de navegação
- Escreva asserções de contagem de consultas em testes de integração para prevenir regressões
Resumo
| Problema | Solução |
|---|---|
| N+1 em navegação única | Include(o => o.Customer) |
| N+1 em navegação de coleção | Include(o => o.Lines) |
| Explosão cartesiana com múltiplas coleções | .AsSplitQuery() |
| Carregando mais dados do que o necessário | Projeção Select() |
| Dados relacionados condicionais | Entry().Collection().Query().LoadAsync() |
| N+1 invisível (lazy loading) | Desabilitar UseLazyLoadingProxies(), usar carregamento explícito |
| JOINs lentos apesar do Include correto | Adicionar 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.