A maior e melhor Black Friday que já fizemos! Descontos a partir de 50% em até 12x sem juros - Garanta seu Desconto

Performance no EF Core: guia passo a passo com 13 técnicas

Performance no EF Core: guia passo a passo com 13 técnicas

Melhorar a performance no EF Core raramente exige trocar de ORM, porque quase sempre o gargalo está na consulta que escrevemos. Neste guia, você vai ver 13 técnicas organizadas em uma ordem prática, do diagnóstico até as operações de escrita.

Por que a performance no EF Core cai em produção

Muitos desenvolvedores concluem que o EF Core é lento e partem para outra ferramenta. Na prática, o problema costuma ser outro: o EF Core facilita escrever consultas que trazem muito mais dados do que a tela precisa.

Em desenvolvimento, o banco tem poucas centenas de linhas e tudo responde em milissegundos. Por isso, os testes de integração raramente revelam o problema.

Quando a tabela chega a milhões de registros, o mesmo endpoint passa a demorar segundos. Nesse momento, a performance no EF Core vira um assunto urgente, e não um detalhe de implementação.

Os exemplos deste artigo usam o EF Core 10, versão LTS atual junto com o .NET 10, e um domínio simples de pedidos. Além disso, as consultas foram pensadas para o PostgreSQL, embora as ideias valham também para SQL Server, MySQL e Oracle.

public class Order
{
    public Guid Id { get; set; }
    public string Number { get; set; } = string.Empty;
    public string CustomerId { get; set; } = string.Empty;
    public string CustomerEmail { get; set; } = string.Empty;
    public OrderStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public List<OrderItem> Items { get; set; } = [];
}

public class OrderItem
{
    public Guid Id { get; set; }
    public string ProductName { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public Guid OrderId { get; set; }
    public Order Order { get; set; } = null!;
}

Meça a performance no EF Core antes de otimizar

O erro mais comum acontece antes de qualquer linha de código mudar. Alguém percebe uma consulta lenta, coloca um cache na frente e o endpoint parece resolvido.

Contudo, a consulta lenta continua lá. Ela apenas roda menos vezes e volta a doer quando o cache expira ou os dados mudam com frequência.

Então, o primeiro passo é descobrir qual SQL o seu LINQ realmente gera. Para isso, ative o log de comandos apenas no ambiente de desenvolvimento.

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseNpgsql(connectionString);

    if (builder.Environment.IsDevelopment())
    {
        // Mostra o SQL gerado e os valores dos parâmetros no console
        options.LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging()
            .EnableDetailedErrors();
    }
});

O método LogTo escreve cada comando SQL no console. Já o EnableSensitiveDataLogging inclui os valores dos parâmetros, mas nunca deve ficar ativo em produção, pois expõe e-mails e outros dados pessoais.

Com o SQL em mãos, rode a consulta com o plano de execução, usando EXPLAIN ANALYZE no PostgreSQL. Em seguida, observe três sinais:

  • Se o banco faz uma varredura completa da tabela ou usa um índice.
  • Quantas linhas o banco devolve em comparação com o que a tela exibe.
  • Quantas idas ao banco o seu código faz para atender uma única requisição.

Para consultas que você pretende ajustar várias vezes, crie um benchmark com o BenchmarkDotNet. Assim, cada mudança na performance no EF Core passa a ter um número concreto para comparação.

Busque menos dados do banco

Quase todo problema de performance no EF Core tem a mesma causa: a consulta move mais dados do que o código precisa. As três técnicas a seguir atacam exatamente esse ponto.

Índices como base da performance no EF Core

As migrations criam índices automaticamente para chaves primárias e estrangeiras. No entanto, elas não criam índices para as colunas que você usa em filtros e ordenações.

Se a listagem de pedidos filtra por status e ordena pela data de criação, o banco precisa varrer a tabela a cada requisição. Por isso, declare o índice na configuração da entidade.

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(x => x.Id);

        builder.HasIndex(x => x.Number)
            .IsUnique();

        // Índice composto: igualdade primeiro, ordenação depois
        builder.HasIndex(x => new { x.Status, x.CreatedAt });
    }
}

A ordem das colunas importa. Esse índice atende filtros por Status e filtros por Status com ordenação por CreatedAt, mas não ajuda quando você ordena apenas por CreatedAt.

Quando a maioria das consultas olha só uma fatia da tabela, um índice filtrado custa menos para manter.

// Indexa apenas pedidos que ainda não foram concluídos
builder.HasIndex(x => x.CreatedAt)
    .HasFilter("\"Status\" NOT IN (4, 5)");

Lembre também que índices não são gratuitos para a performance no EF Core. Cada INSERT, UPDATE e DELETE precisa atualizá-los, então crie apenas os que correspondem às consultas que você realmente executa.

Filtre no banco e não na memória

Esse erro aparece muito em código de iniciantes e, às vezes, em código gerado por IA. Geralmente ele se esconde dentro de um método auxiliar.

// Carrega a tabela inteira para filtrar em memória
var orders = await dbContext.Orders.ToListAsync(cancellationToken);

var pending = orders
    .Where(o => o.Status == OrderStatus.Processing)
    .ToList();

No momento em que você chama ToListAsync, a consulta executa e a performance no EF Core já foi comprometida. A partir daí, todo o resto acontece na memória da aplicação, depois de o banco enviar cada linha pela rede.

Mantenha o filtro dentro do IQueryable, para que ele vire uma cláusula WHERE.

var pending = await dbContext.Orders
    .Where(o => o.Status == OrderStatus.Processing
        && o.CreatedAt >= since)
    .ToListAsync(cancellationToken);

A mesma regra vale para ordenação, agrupamento e agregação. Métodos como Count, Any, Sum e Max viram SQL quando você os chama sobre o IQueryable, mas rodam em memória quando vêm depois do ToListAsync.

Projeções e o maior ganho de performance no EF Core

Carregar uma entidade completa significa ler todas as colunas mapeadas, inclusive as que o endpoint nunca devolve. Em vez disso, projete o resultado em um record com apenas os campos necessários.

public record OrderResponse(
    Guid Id,
    string Number,
    OrderStatus Status,
    DateTime CreatedAt);

var orders = await dbContext.Orders
    .Where(o => o.Status == OrderStatus.Processing)
    .OrderByDescending(o => o.CreatedAt)
    .Select(o => new OrderResponse(o.Id, o.Number, o.Status, o.CreatedAt))
    .ToListAsync(cancellationToken);

Com isso, o EF Core gera um SELECT com exatamente essas quatro colunas. Assim, você reduz o volume lido pelo banco, o tráfego de rede e ainda dispensa o rastreamento de mudanças.

Em listagens com dezenas de milhares de linhas, essa é a técnica que mais melhora a performance no EF Core. Além disso, projeções combinam bem com agregações calculadas no próprio banco.

var summaries = await dbContext.Orders
    .Where(o => o.CreatedAt >= since)
    .Select(o => new OrderSummary(
        o.Number,
        o.Items.Count,
        o.Items.Sum(i => i.Quantity)))
    .ToListAsync(cancellationToken);

Nesse caso, o banco calcula a contagem e a soma, enquanto a aplicação não cria nenhum objeto OrderItem.

Carregue os dados da forma correta

Agora que a consulta busca as linhas e colunas certas, vale olhar como o EF Core entrega esses dados para a aplicação.

Change tracking e performance no EF Core

Por padrão, o EF Core rastreia toda entidade que uma consulta retorna. Ele guarda um snapshot dos valores originais para descobrir o que mudou no SaveChanges, e isso consome memória e CPU em cada linha.

Em consultas somente de leitura, esse trabalho só prejudica a performance no EF Core. Por isso, use o AsNoTracking.

var delivered = await dbContext.Orders
    .AsNoTracking()
    .Where(o => o.Status == OrderStatus.Delivered)
    .ToListAsync(cancellationToken);

Quando a consulta carrega a mesma entidade mais de uma vez, prefira o AsNoTrackingWithIdentityResolution. Ele também evita o rastreamento, mas devolve uma única instância para cada registro.

Se o contexto atende principalmente leituras, como em um módulo de relatórios, inverta o padrão com UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking). Por outro lado, quando você já projeta com Select em um record, o AsNoTracking não muda nada, pois não existe entidade para rastrear.

Use o Include com intenção

O Include carrega entidades relacionadas em uma única ida ao banco. Contudo, ele também transforma uma consulta pequena em uma consulta enorme, porque traz todas as colunas de todos os itens.

Se você precisa apenas do nome dos produtos, projete em vez de incluir.

var orders = await dbContext.Orders
    .Where(o => o.Status == OrderStatus.Processing)
    .Select(o => new OrderWithProducts(
        o.Number,
        o.Items.Select(i => i.ProductName).ToList()))
    .ToListAsync(cancellationToken);

Quando você realmente precisa das entidades, use um include filtrado, como Include(o => o.Items.Where(i => i.Quantity > 0)). Dessa forma, o EF Core não carrega a coleção inteira.

A armadilha maior é o lazy loading. Com os proxies ativos, acessar order.Items dentro de um laço dispara uma consulta por pedido, e 500 pedidos viram 501 idas ao banco.

Esse é o famoso problema N+1, que derruba a performance no EF Core sem deixar nada visível no código. Portanto, carregue explicitamente o que precisa e mantenha o lazy loading desligado em serviços de produção.

Divida coleções grandes com AsSplitQuery

Quando você inclui duas ou mais coleções, o EF Core junta tudo em um único resultado. Um pedido com 10 itens e 10 eventos de status gera 100 linhas, repetindo as colunas do pedido em cada uma delas.

Esse efeito se chama explosão cartesiana e cresce por multiplicação. O AsSplitQuery resolve isso emitindo uma consulta por coleção.

var orders = await dbContext.Orders
    .Include(o => o.Items)
    .Include(o => o.StatusEvents)
    .AsSplitQuery()
    .Where(o => o.CreatedAt >= since)
    .ToListAsync(cancellationToken);

No entanto, a divisão nem sempre deixa a consulta mais rápida. Ela troca uma ida ao banco por várias, e isso pesa quando as coleções são pequenas ou a latência com o banco é alta.

Além disso, as consultas separadas não garantem consistência entre si sem uma transação. Então, meça com os seus dados antes de adotar o AsSplitQuery como padrão.

Mantenha a performance no EF Core quando a tabela cresce

Uma consulta rápida com 10 mil linhas pode travar com 10 milhões, e a performance no EF Core cai junto. As duas técnicas seguintes tratam justamente das consultas que pioram conforme o volume aumenta.

Troque offset por paginação keyset

A paginação por offset é o caminho mais óbvio. Nela, o Skip vira OFFSET no SQL, e esse comando tem custo.

var page = await dbContext.Orders
    .OrderByDescending(o => o.CreatedAt)
    .Skip(pageNumber * pageSize)
    .Take(pageSize)
    .ToListAsync(cancellationToken);

Para devolver as linhas a partir da posição 100 mil, o banco percorre e descarta todas as anteriores, mesmo quando usa um índice. Assim, as primeiras páginas respondem rápido, mas a última página fica muito lenta.

A paginação keyset, também chamada de cursor, pede as linhas que vêm depois de uma posição conhecida.

var page = await dbContext.Orders
    .Where(o => o.CreatedAt < lastCreatedAt
        || (o.CreatedAt == lastCreatedAt && o.Id < lastId))
    .OrderByDescending(o => o.CreatedAt)
    .ThenByDescending(o => o.Id)
    .Take(pageSize)
    .ToListAsync(cancellationToken);

Nesse modelo, o banco vai direto à posição no índice e lê apenas as linhas da página. A comparação pelo Id desempata registros com a mesma data, o que evita linhas repetidas ou puladas.

A contrapartida é que você não consegue saltar para uma página aleatória. Mesmo assim, para rolagem infinita ou botões de próximo e anterior, a técnica protege a performance no EF Core de forma consistente.

Cuidado com listas grandes no Contains

Esse problema pega até desenvolvedores experientes, porque o código parece totalmente razoável.

var orders = await dbContext.Orders
    .Where(o => customerIds.Contains(o.CustomerId))
    .ToListAsync(cancellationToken);

No EF Core 10, o Contains sobre uma coleção vira um IN com um parâmetro para cada elemento, e o EF completa a lista com parâmetros extras para reaproveitar planos. Isso funciona bem com 50 identificadores, mas cada banco impõe um teto.

  • Oracle: até mil expressões em uma lista IN.
  • SQL Server: até 2.100 parâmetros por comando.
  • No PostgreSQL e no MySQL, o limite é muito maior, mas o planejador fica lento bem antes dele.

A falha engana porque depende do volume. Por isso, o código passa nos testes e quebra em produção quando alguém importa um lote grande.

A solução mais simples é dividir os identificadores em lotes.

const int batchSize = 500;
var results = new List<Order>();

foreach (var batch in customerIds.Chunk(batchSize))
{
    var chunk = await dbContext.Orders
        .AsNoTracking()
        .Where(o => batch.Contains(o.CustomerId))
        .ToListAsync(cancellationToken);

    results.AddRange(chunk);
}

Outra opção é envolver a coleção com EF.Parameter(customerIds), que envia um único parâmetro com um array JSON. Para volumes realmente grandes, porém, levar os identificadores para uma tabela temporária e fazer um JOIN escala muito melhor.

Evite repetir o mesmo trabalho

Neste ponto, as consultas já buscam os dados certos de forma eficiente. Os ganhos restantes vêm de não refazer o mesmo trabalho em cada requisição.

Compiled queries para caminhos quentes

Toda consulta LINQ passa por uma tradução para SQL, etapa que também conta na performance no EF Core. O EF Core guarda essa tradução em cache, mas, em um caminho executado milhares de vezes por segundo, até a busca nesse cache aparece no profiler.

Uma compiled query faz a tradução uma única vez e reaproveita o resultado durante toda a vida da aplicação.

private static readonly Func<AppDbContext, string, Task<Order?>> GetByNumber =
    EF.CompileAsyncQuery((AppDbContext context, string number) =>
        context.Orders
            .AsNoTracking()
            .FirstOrDefault(o => o.Number == number));

public Task<Order?> GetOrderAsync(string number) =>
    GetByNumber(dbContext, number);

Seja criterioso com essa técnica. Ela só compensa em consultas muito frequentes e de formato fixo, porque você não consegue compor uma compiled query dinamicamente como faz com um IQueryable.

Cache do resultado com HybridCache

O cache é o último passo da performance no EF Core, e não o primeiro. Ele faz sentido quando a consulta já está otimizada e, mesmo assim, roda muito mais vezes do que os dados mudam.

A partir do .NET 9, o HybridCache resolve bem esse cenário.

public async Task<OrderResponse?> GetAsync(string number, CancellationToken ct)
{
    return await hybridCache.GetOrCreateAsync(
        $"order:{number}",
        async token => await dbContext.Orders
            .AsNoTracking()
            .Where(o => o.Number == number)
            .Select(o => new OrderResponse(o.Id, o.Number, o.Status, o.CreatedAt))
            .FirstOrDefaultAsync(token),
        cancellationToken: ct);
}

O HybridCache combina memória local com um cache distribuído, como o Redis, e evita o efeito manada. Quando cem requisições pedem a mesma chave ausente, apenas uma chega ao banco.

Ainda assim, a invalidação continua sendo a parte difícil. Portanto, guarde apenas dados que podem ficar um pouco desatualizados e remova as entradas quando os registros mudarem.

Performance no EF Core nas operações de escrita

Escritas mal planejadas causam tantos incidentes quanto leituras lentas. As três técnicas finais cuidam desse lado da aplicação.

Nunca chame SaveChanges dentro de um laço

Esse padrão é comum e caro, porque cada chamada abre uma transação e espera a resposta do banco.

// Uma ida ao banco por pedido
foreach (var order in orders)
{
    order.Status = OrderStatus.Shipped;
    await dbContext.SaveChangesAsync(cancellationToken);
}

Com mil pedidos, são mil idas ao banco, e a latência de rede domina a performance no EF Core. Então, mova a chamada para fora do laço.

foreach (var order in orders)
{
    order.Status = OrderStatus.Shipped;
}

await dbContext.SaveChangesAsync(cancellationToken);

Agora o EF Core agrupa os comandos dentro de uma única transação. Com isso, ou todos os pedidos mudam de status, ou nenhum muda.

Essa abordagem funciona bem para algumas centenas de linhas. Para inserir dezenas de milhares de registros, porém, vale usar uma biblioteca dedicada a operações em massa.

ExecuteUpdate e ExecuteDelete para alterações em massa

Quando você aplica a mesma alteração a muitas linhas, carregar cada entidade e comparar snapshots é desperdício. Nesse caso, use o ExecuteUpdateAsync.

await dbContext.Orders
    .Where(o => o.Status == OrderStatus.Processing
        && o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Status, OrderStatus.Cancelled)
        .SetProperty(o => o.UpdatedAt, DateTime.UtcNow),
        cancellationToken);

O EF Core envia um único UPDATE com WHERE, sem materializar entidades nem acionar o change tracker. Além disso, no EF Core 10 o método aceita uma lambda comum, o que facilita montar setters condicionais.

O ExecuteDeleteAsync segue a mesma lógica para exclusões. Contudo, ambos ignoram o change tracker e os interceptors do SaveChanges, então auditoria e eventos de domínio ligados a ele não disparam.

Por isso, use esses métodos para manutenção e transições de estado em massa. Já as regras de negócio devem continuar passando pelo SaveChanges.

Controle o ciclo de vida do DbContext

O DbContext foi pensado para viver pouco. Ele é barato de criar, não é thread safe e mantém em memória toda entidade rastreada até ser descartado.

Em aplicações web, o registro como scoped preserva a performance no EF Core, porque o escopo acompanha a requisição. Por outro lado, em serviços de background, um contexto reaproveitado por horas acumula entidades e se comporta como um vazamento de memória.

Nesses cenários, use uma factory para controlar o ciclo de vida.

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(connectionString));

// Dentro do serviço de background, um contexto novo por iteração
await using var context = await dbContextFactory.CreateDbContextAsync(ct);

var pending = await context.Orders
    .AsNoTracking()
    .Where(o => o.Status == OrderStatus.Created)
    .ToListAsync(ct);

Para serviços de alto volume, o AddDbContextPool reaproveita instâncias e reinicia o estado entre usos. Vale lembrar também que o pool do contexto não substitui o pool de conexões do driver, assunto que exploramos em como funcionam as conexões no SQL Server e no PostgreSQL.

Ordem prática para melhorar a performance no EF Core

Treze técnicas são muita coisa para lembrar quando um endpoint está dando timeout. Por isso, a tabela abaixo resume o impacto e o esforço de cada uma.

Técnica Impacto típico Quando aplicar Esforço
Índices Muito alto Filtros e ordenações sem índice Baixo
Filtrar no banco Muito alto Where depois de ToList Baixo
Projeções Muito alto Entidades no lugar de DTOs Baixo
AsNoTracking Médio Leituras que retornam entidades Muito baixo
Include consciente Alto Cadeias de include ou lazy loading Médio
AsSplitQuery Variável Duas ou mais coleções incluídas Muito baixo
Paginação keyset Alto em páginas profundas Tabelas grandes Médio
Lotes no Contains Evita falhas Milhares de identificadores Médio
Compiled queries Baixo Consultas quentes e fixas Médio
Cache Muito alto Dados lidos mais do que alterados Alto
SaveChanges fora do laço Muito alto Gravações dentro de laços Muito baixo
ExecuteUpdate Alto Mesma alteração em muitas linhas Baixo
Ciclo de vida do contexto Evita vazamentos Background e paralelismo Baixo

A sequência que funciona melhor começa sempre pela medição, porque adivinhar custa mais tempo do que medir. Em seguida, ajuste o que a consulta busca, aplicando índices, filtros e projeções nessa ordem.

Depois disso, revise como os dados chegam, com o rastreamento e a estratégia de include. Então, trate da paginação e dos limites de parâmetros, que definem como a consulta se comporta quando a tabela cresce.

Por fim, considere compiled queries e cache. Ambos adicionam complexidade e perdem relevância quando as etapas anteriores já fizeram o trabalho pesado.

A documentação oficial sobre consultas eficientes no Entity Framework Core aprofunda vários desses pontos e vale como referência permanente.

Conclusão sobre performance no EF Core

Na maioria dos sistemas, a performance no EF Core depende mais das consultas do que do próprio ORM. Índices, filtros no banco e projeções resolvem a maior parte dos problemas com pouco esforço.

As demais técnicas entram conforme o volume cresce ou conforme as escritas se tornam críticas. Em todos os casos, meça antes, aplique uma mudança por vez e compare os números.

Com esse método, a performance no EF Core deixa de ser uma surpresa em produção e passa a ser uma decisão consciente do time.

FAQ

O EF Core é mais lento que o Dapper?

Em consultas bem escritas, com projeção e sem rastreamento, a diferença costuma ser pequena. A maior parte da lentidão vem de consultas que trazem dados demais, e não do ORM em si.

Devo usar AsNoTracking em todas as consultas?

Use em todas as leituras que retornam entidades e não serão alteradas. Quando a consulta já projeta em um DTO, ele não faz diferença.

AsSplitQuery sempre melhora a performance no EF Core?

Não, porque ele troca uma consulta grande por várias idas ao banco. Meça com os seus dados antes de adotá-lo.

Quando vale usar compiled queries?

Apenas em consultas de formato fixo executadas com muita frequência. Para o restante, o cache interno do EF Core já basta.

Qual é a primeira coisa a fazer quando uma consulta está lenta?

Ative o log do SQL gerado e analise o plano de execução no banco. Assim, você corrige a causa em vez de esconder o sintoma com cache.