EF Core .NET C# Performance Backend

Performance no Entity Framework Core: N+1, índices, projeções e bulk operations

Guia prático de otimização do EF Core: como identificar e corrigir o problema N+1, usar projeções com Select, configurar índices.

N
Neryx Digital Architects
16 de janeiro de 2026
13 min de leitura
220 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Entity Framework Core facilita demais escrever código que parece eficiente mas destrói a performance em produção. O problema clássico — o famoso N+1 — é invisível durante o desenvolvimento com poucos registros e aparece de forma brutal quando o banco tem 100 mil linhas.

Este guia cobre os problemas de performance mais comuns no EF Core e como corrigir cada um deles.

O problema N+1: o mais comum e mais destrutivo

N+1 acontece quando você carrega uma lista de entidades e depois acessa uma propriedade de navegação dentro de um loop — gerando uma query por registro:

// ERRADO — gera N+1 queries
var pedidos = await _context.Pedidos.ToListAsync(); // 1 query: SELECT * FROM pedidos

foreach (var pedido in pedidos) { // NOVA query para cada pedido: SELECT * FROM clientes WHERE id = @id Console.WriteLine(pedido.Cliente.Nome); // Com 1.000 pedidos = 1.001 queries ao banco! }

Solução 1: Eager Loading com Include

// CORRETO — carrega tudo em uma query com JOIN
var pedidos = await _context.Pedidos
    .Include(p => p.Cliente)
    .Include(p => p.Itens)
        .ThenInclude(i => i.Produto) // Include aninhado
    .ToListAsync();

// Apenas 1 query com JOINs — independente de quantos pedidos

Cuidado: Include em coleções grandes pode trazer dados demais. Se um pedido tem 50 itens e você lista 100 pedidos, você carrega 5.000 itens — mesmo que só exiba o número de itens na tela.

Solução 2: Projeções com Select (melhor prática)

// MELHOR — traz só o que a tela precisa
var pedidos = await _context.Pedidos
    .Select(p => new PedidoResumoDto(
        p.Id,
        p.Cliente.Nome,        // EF Core resolve o JOIN automaticamente
        p.Status.ToString(),
        p.Itens.Count,         // COUNT no banco, não em memória
        p.Itens.Sum(i => i.Preco * i.Quantidade) // SUM no banco
    ))
    .ToListAsync();

// Gera SQL eficiente com subquery/JOIN só para os campos necessários // Nunca carrega entidades completas desnecessariamente

Projeções com Select são sempre mais eficientes que Include para listagens, porque o EF Core traduz a expressão para SQL e traz só os campos necessários.

Lazy Loading: o modo que parece conveniente e causa N+1

// NUNCA habilite lazy loading em aplicações web:
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    opt.UseLazyLoadingProxies(); // EVITE — causa N+1 silenciosamente
    opt.UseNpgsql(connectionString);
});

// Lazy loading parece mágico: pedido.Cliente funciona sem Include
// Mas cada acesso dispara uma nova query ao banco
// Em controllers com serialização JSON de listas = desastre garantido

A regra: use explicit loading (com Include ou Select) sempre. Deixe as queries explícitas — você sabe exatamente o que está indo ao banco.

AsNoTracking: essencial para queries de leitura

// PADRÃO com tracking (operações de escrita):
var pedido = await _context.Pedidos.FindAsync(id);
pedido.Status = StatusPedido.Confirmado;
await _context.SaveChangesAsync(); // EF Core detecta a mudança

// Para LEITURA PURA: sempre use AsNoTracking var pedidos = await _context.Pedidos .AsNoTracking() // não rastreia entidades no ChangeTracker .Include(p => p.Cliente) .Where(p => p.Status == StatusPedido.Confirmado) .ToListAsync();

// AsNoTracking é 20-40% mais rápido em listagens // e reduz consumo de memória significativamente

Configure AsNoTracking como padrão para queries de leitura usando UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) e ative o tracking explicitamente apenas quando necessário.

Paginação: nunca use Skip/Take sem ordernar

// ERRADO — sem ordenação, o resultado é não-determinístico
var pagina = await _context.Pedidos
    .Skip(pageNumber * pageSize)
    .Take(pageSize)
    .ToListAsync();

// CORRETO — sempre ordene antes de paginar var pagina = await _context.Pedidos .AsNoTracking() .OrderByDescending(p => p.CriadoEm) // ordem definida = resultado determinístico .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .Select(p => new PedidoDto(…)) .ToListAsync();

// Para paginação de alta performance em tabelas grandes, use cursor-based pagination: // WHERE id > @ultimoId ORDER BY id LIMIT @pageSize // Muito mais eficiente que OFFSET em tabelas com milhões de registros

Índices: a diferença entre 1ms e 5s

// No DbContext — configuração de índices
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Pedido>(entity =>
    {
        // Índice para busca por cliente (query mais comum)
        entity.HasIndex(p => p.ClienteId)
              .HasDatabaseName("IX_Pedidos_ClienteId");
    // Índice composto para filtrar por status + data
    entity.HasIndex(p => new { p.Status, p.CriadoEm })
          .HasDatabaseName("IX_Pedidos_Status_CriadoEm");

    // Índice único em campo de negócio
    entity.HasIndex(p => p.NumeroPedido)
          .IsUnique()
          .HasDatabaseName("IX_Pedidos_NumeroPedido");
});

modelBuilder.Entity&lt;Item&gt;(entity =>
{
    // Índice em FK para otimizar JOINs
    entity.HasIndex(i => i.PedidoId)
          .HasDatabaseName("IX_Itens_PedidoId");
});

}

// Via migration — para adicionar índice em tabela existente: migrationBuilder.CreateIndex( name: “IX_Pedidos_ClienteId”, table: “pedidos”, column: “cliente_id”);

Regra prática: toda coluna usada em WHERE, JOIN ON ou ORDER BY frequentemente deve ter índice. Use EXPLAIN ANALYZE no PostgreSQL para identificar queries fazendo full scan.

Bulk Operations: INSERT/UPDATE em massa

O EF Core padrão processa registros um por um. Para operações em massa, isso é lento demais:

// LENTO — EF Core padrão: INSERT um por um
foreach (var item in itens)
{
    _context.Itens.Add(item);
}
await _context.SaveChangesAsync(); // N INSERTs individuais

// RÁPIDO — EF Core 8+ nativo com ExecuteUpdateAsync/ExecuteDeleteAsync // Para UPDATE em massa sem carregar entidades: await _context.Pedidos .Where(p => p.Status == StatusPedido.Rascunho && p.CriadoEm < DateTime.UtcNow.AddDays(-30)) .ExecuteUpdateAsync(setters => setters .SetProperty(p => p.Status, StatusPedido.Expirado) .SetProperty(p => p.AtualizadoEm, DateTime.UtcNow)); // Gera: UPDATE pedidos SET status = ‘Expirado’, atualizado_em = NOW() // WHERE status = ‘Rascunho’ AND criado_em < NOW() - INTERVAL ‘30 days’ // Zero entidades carregadas em memória!

// Para DELETE em massa: await _context.Logs .Where(l => l.CriadoEm < DateTime.UtcNow.AddYears(-1)) .ExecuteDeleteAsync();

// Para INSERT em massa com EF Core.BulkExtensions (pacote externo): dotnet add package EFCore.BulkExtensions await _context.BulkInsertAsync(listaDeItens); // muito mais rápido para >100 registros

Compiled Queries: reutilizando o plano de execução

// Para queries executadas frequentemente com os mesmos parâmetros:
private static readonly Func<AppDbContext, Guid, Task<Pedido?>> _getPedidoById =
    EF.CompileAsyncQuery((AppDbContext ctx, Guid id) =>
        ctx.Pedidos
           .Include(p => p.Itens)
           .FirstOrDefault(p => p.Id == id));

// Uso — o EF Core reutiliza o plano compilado, sem re-parsear a expressão var pedido = await _getPedidoById(_context, pedidoId);

// Útil para queries em hot paths (chamadas centenas de vezes por segundo)

Quando sair do EF Core e usar Dapper

EF Core é excelente para operações de escrita (com o ChangeTracker gerenciando entidades). Para queries de leitura complexas — joins múltiplos, agregações, relatórios — Dapper frequentemente produz SQL mais eficiente com menos overhead:

// Query de relatório — difícil expressar em LINQ, muito mais claro em SQL
public async Task<RelatorioVendasDto> GetRelatorioAsync(DateOnly inicio, DateOnly fim)
{
    const string sql = @"
        SELECT
            DATE_TRUNC('day', p.confirmado_em) AS data,
            COUNT(p.id) AS total_pedidos,
            SUM(p.total) AS faturamento,
            AVG(p.total) AS ticket_medio,
            COUNT(DISTINCT p.cliente_id) AS clientes_unicos
        FROM pedidos p
        WHERE p.status = 'Confirmado'
          AND p.confirmado_em::date BETWEEN @Inicio AND @Fim
        GROUP BY DATE_TRUNC('day', p.confirmado_em)
        ORDER BY data";
var rows = await _connection.QueryAsync&lt;RelatorioVendasRow&gt;(sql, new { Inicio = inicio, Fim = fim });
return new RelatorioVendasDto(rows.ToList());

}

A regra prática: EF Core para escrita (commands), Dapper para leituras complexas (queries). Essa divisão é a essência do lado de leitura no CQRS.

Diagnosticando queries lentas

// Habilitar logging de queries lentas em desenvolvimento
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    opt.UseNpgsql(connectionString)
       .LogTo(Console.WriteLine, LogLevel.Information)
       .EnableSensitiveDataLogging() // mostra valores dos parâmetros (só em dev!)
       .EnableDetailedErrors();      // mensagens de erro mais descritivas
});

// Em produção: use o slow query log do PostgreSQL (log_min_duration_statement = 100ms) // + pg_stat_statements para identificar as queries mais custosas do sistema

// Identificando N+1 programaticamente (usando pacote DetectOpenHandlesDotNet ou MiniProfiler): dotnet add package MiniProfiler.AspNetCore.Mvc dotnet add package MiniProfiler.EntityFrameworkCore // Acesse /profiler/results em desenvolvimento para ver queries por request

Checklist de performance EF Core

  • Nunca use lazy loading em aplicações web
  • AsNoTracking() em toda query de leitura
  • Use Select para projetar só os campos necessários
  • Índices em todas as colunas de filtro, JOIN e ordenação frequentes
  • Paginação sempre com OrderBy definido
  • Bulk operations com ExecuteUpdateAsync/ExecuteDeleteAsync para operações em massa
  • Queries complexas de relatório: Dapper em vez de LINQ
  • Ative slow query log no PostgreSQL em produção

Conclusão

A maioria dos problemas de performance com EF Core vem de poucos padrões recorrentes: N+1 por falta de Include ou projeção, ausência de índices em colunas de filtro, e carregamento de dados desnecessários sem AsNoTracking ou Select. Identificar e corrigir esses padrões geralmente resolve 80% dos problemas de performance de banco de dados em aplicações .NET.

Se você tem queries lentas em produção e quer uma análise de performance do seu banco e camada de dados, a Neryx pode ajudar com revisão de código e infraestrutura. Consultoria inicial gratuita.

Leitura complementar:

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.