Destino Dev

Blog

EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote

Por John Pires · 19 de março de 2026

EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote

1) Problema real que resolve

Contexto operacional

Em sistemas que processam sincronizações, importações ou integrações com outros serviços, você vai enfrentar duas operações recorrentes: aplicar atualizações em massa e inserir registros novos sem criar duplicatas. Essas operações precisam ser rápidas, consistentes e previsíveis — especialmente quando os volumes crescem para milhares ou milhões de linhas.

Consequências de uma solução frágil

Uma abordagem ingênua (carregar tudo para memória, decidir por entidade e chamar Add/Update uma a uma) funciona em protótipos, mas falha em produção. A falha vem em forma de uso excessivo de memória, travamentos por timeout, lock contention no banco e latência alta. Ou pior: inconsistência por concorrência entre múltiplos processos.

Este artigo mostra padrões práticos para resolver upserts (insert-or-update), updates/deletes em lote usando ExecuteUpdate/ExecuteDelete do EF Core e alternativas (MERGE/ON CONFLICT, bibliotecas de bulk). O foco é reduzir I/O, evitar tracking pesado, e preservar atomicidade onde necessário.

2) Abordagem ingênua vs abordagem otimizada

Abordagem ingênua: carregar e salvar

A abordagem ingênua típica é: carregar entidades do banco, comparar em memória, aplicar mudanças e chamar SaveChanges. Exemplo comum em código legado:

// O que faz: carrega todos os produtos modificados, atualiza em memória e salva; simples, porém pesado em memória e consultas
var existing = await context.Products.Where(p => skus.Contains(p.Sku)).ToListAsync();
foreach (var prod in payload) {
    var match = existing.SingleOrDefault(e => e.Sku == prod.Sku);
    if (match == null) context.Products.Add(prod);
    else { match.Price = prod.Price; match.UpdatedAt = DateTime.UtcNow; }
}
await context.SaveChangesAsync();

O problema deste padrão: para muitos registros a ToListAsync() consome muita memória; além disso há muitas operações de comparação em CPU e SaveChanges gera comandos individuais se o número de entidades for grande.

Abordagem otimizada

Abordagens otimizadas reduziriam o número de round-trips e evitariam tracking desnecessário. As opções mais comuns:

  • Executar um MERGE/ON CONFLICT no banco via ExecuteSqlRaw para delegar o upsert ao servidor.
  • Usar ExecuteUpdate/ExecuteDelete (EF Core 7+) para atualizações/deletes em massa sem carregar entidades.
  • Usar bibliotecas de bulk (EFCore.BulkExtensions, Z.BulkOperations) para operações massivas eficientes.
  • Fatiar (chunk) dados e usar AddRange/UpdateRange com SaveChanges em transações curtas.

Escolher entre essas opções envolve trade-offs de portabilidade, complexidade e performance. Abaixo vamos implementar e comparar técnicas práticas.

3) Implementação com código real

Modelo e DbContext

Primeiro: um modelo simples para exemplificar. Garantimos índice único na coluna de integração (Sku) e um DbContext básico.

// O que faz: define a entidade e o contexto. Use este código como base para os exemplos seguintes.
public class Product
{
    public int Id { get; set; }
    public string Sku { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public DateTime UpdatedAt { get; set; }
}

public class AppDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .HasIndex(p => p.Sku)
            .IsUnique();
    }
}

Esse contexto assume a configuração padrão de conexão no OnConfiguring ou via DI. O índice único em Sku é crítico para upsert por conflito.

Upsert via SQL nativo: Postgres (ON CONFLICT)

Contexto: quando você tem controle do SQL ou usa Postgres, o mais simples e eficiente é delegar o upsert ao servidor usando INSERT ... ON CONFLICT. Ideal para cargas em lote via ExecuteSqlRawAsync.

// O que faz: insere ou atualiza um registro no Postgres usando ON CONFLICT, sem carregar entidades no EF.
var sql = @"
INSERT INTO products (sku, name, price, updated_at)
VALUES (@p0, @p1, @p2, @p3)
ON CONFLICT (sku) DO UPDATE
  SET name = EXCLUDED.name,
      price = EXCLUDED.price,
      updated_at = EXCLUDED.updated_at;";

await context.Database.ExecuteSqlRawAsync(sql, sku, name, price, DateTime.UtcNow);

Resultado: transação é executada no servidor, mínimo tráfego de rede. Para muitos itens, construa um batched SQL (VALUES (...),(...)) ou use COPY + temp table + MERGE.

Upsert via SQL nativo: SQL Server (MERGE)

Contexto: no SQL Server, MERGE é a alternativa para combinar insert/update. Cuidado com bugs históricos do MERGE em versões antigas; teste antes de aplicar.

// O que faz: realiza MERGE no SQL Server para inserir ou atualizar um registro por sku
var merge = @"
MERGE INTO Products AS target
USING (VALUES (@sku, @name, @price, @updated)) AS source (Sku, Name, Price, UpdatedAt)
ON target.Sku = source.Sku
WHEN MATCHED THEN
    UPDATE SET Name = source.Name, Price = source.Price, UpdatedAt = source.UpdatedAt
WHEN NOT MATCHED THEN
    INSERT (Sku, Name, Price, UpdatedAt)
    VALUES (source.Sku, source.Name, source.Price, source.UpdatedAt);
";
await context.Database.ExecuteSqlRawAsync(merge, skuParam, nameParam, priceParam, updatedParam);

Resultado: padrão eficiente, mas teste planos de execução e locks sob carga. Em cenários de altíssima concorrência, considere usar tabelas temporárias + staging + set-base MERGE em janela controlada.

ExecuteUpdate/ExecuteDelete: atualizar e deletar sem materializar

Contexto: EF Core 7 introduziu ExecuteUpdate/ExecuteDelete para operar diretamente no servidor via LINQ. Isso evita carregar entidades e é ótimo para regras que podem ser expressas em LINQ.

// O que faz: aumenta preço em 10% para produtos baratos e atualiza UpdatedAt sem carregar entidades
await context.Products
    .Where(p => p.Price < 10m)
    .ExecuteUpdateAsync(s => s
        .SetProperty(p => p.Price, p => p.Price * 1.1m)
        .SetProperty(p => p.UpdatedAt, p => DateTime.UtcNow));

// O que acontece: comando UPDATE é executado diretamente no banco; tracking não é gerado para entidades afetadas.

ExecuteDelete segue o mesmo padrão:

// O que faz: remove produtos obsoletos com UpdatedAt antigo sem selecionar entidades
await context.Products
    .Where(p => p.UpdatedAt < cutoff)
    .ExecuteDeleteAsync();

// O que acontece: comando DELETE enviado ao banco; evita round-trip de SELECT+DELETE.

Bulk libraries: quando usar

Contexto: para cargas massivas (milhares/100k+), bibliotecas como EFCore.BulkExtensions ou Z.BulkOperations reduzem drasticamente tempo ao usar BCP, bulk insert/update nativo e operações em lote.

// O que faz: usa EFCore.BulkExtensions para inserir ou atualizar lista de produtos eficientemente
// Requer: dotnet add package EFCore.BulkExtensions
await context.BulkInsertOrUpdateAsync(productsList);

// O que acontece: a biblioteca escolhe a melhor estratégia (bulk insert/update) para o provedor, reduz round-trips.

Observação: essas bibliotecas podem exigir considerações especiais para chaves auto-incrementais e triggers; leia a documentação para configurações provider-specific.

4) Testes e validação

Métricas básicas e benchmarking

Não adivinhe. Meça. Use Stopwatch e logs do EF para comparar abordagens em ambientes semelhantes. Exemplo simples:

// O que faz: mede tempo de execução de uma estratégia (ex: bulk vs addrange) com Stopwatch
var sw = Stopwatch.StartNew();
await RunUpsertStrategyAsync(context, items); // implemente estratégias diferentes
sw.Stop();
Console.WriteLine($"Tempo: {sw.ElapsedMilliseconds} ms");

// O que acontece: Número simples que permite comparar abordagens em mesma carga; combine com metrics do banco (IO, CPU).

Registre também contadores de banco (pg_stat_statements, DMVs do SQL Server) para ver IO e planos. Pequenas vantagens em dev podem virar problemas em produção.

Testes de integridade e concorrência

Valide atomicidade: crie cenários com múltiplos processos simultâneos que tentam inserir/atualizar o mesmo Sku. Verifique duplicatas e deadlocks. Use transações com isolamento adequado quando necessário.

Para cenários idempotentes, registre um hash do payload e aplique deduplicação na camada de ingestão. Isso reduz retrys duplicados e simplifica upserts.

Testes de regressão automatizados

Monte testes de integração que carreguem um banco de teste (Docker) e rodem cenários de carga reduzida. Não confie apenas em testes unitários: operações em lote dependem fortemente do comportamento do banco.

Dica: mantenha scripts SQL de comparação de resultados (counts, sums, checksums) para validar que duas estratégias têm a mesma semântica.

5) Considerações de produção

Transações, locks e isolamento

Operações em lote podem gerar locks longos. Estratégias para mitigar:

  1. Fazer operações em janelas menores (chunking).
  2. Usar tabelas de staging e aplicar transformações em horários off-peak.
  3. Escolher isolation level apropriado (READ COMMITTED SNAPSHOT no SQL Server reduz bloqueios de leitura).

Se usar MERGE/ON CONFLICT, teste o impacto de índices e executáveis paralelos. Locks por pagina ou tabela podem causar contensão sob concorrência alta.

Trabalhando com chaves e identidade

Ao usar Bulk Insert/Update e MERGE, preste atenção a colunas identity/serial. Algumas bibliotecas trazem IDs gerados de volta; outras não. Em cenários de replicação/CDC, atualize correctamente as colunas de auditoria.

Para evitar conflitos, prefira chaves naturais (como Sku) quando possível ou garanta controle exclusivo do range de IDs entre processos produtores.

Erros comuns, armadilhas e dicas práticas

Listagem direta de armadilhas e como evitá-las:

  • Concorrência de múltiplos processos: sem controle, upserts por MERGE podem causar deadlocks. Use retry com backoff e retry policies (Polly).
  • SQL injection: não concatene strings SQL. Use parâmetros ou ExecuteSqlInterpolatedAsync.
  • Planos de execução: operações em lote podem gerar planos sub-ótimos. Atualize estatísticas e analise EXPLAIN/SHOWPLAN.
  • Tracking massivo: evitar ToListAsync() com milhões de registros. Use NoTracking, ExecuteUpdate ou Bulk libs.
  • MERGE bugs: algumas versões do SQL Server tiveram problemas com MERGE; testar antes de adotar em produção.

Dica: quando possível, delegue lógica pesada ao banco (ON CONFLICT, MERGE) e mantenha a camada .NET como orquestradora. Isso reduz latência e tráfego.

6) Conclusao

Resumo prático

Para operações em lote com EF Core: prefira ExecuteUpdate/ExecuteDelete quando a lógica couber em expressões LINQ e você quiser evitar materialização. Para upserts, use ON CONFLICT (Postgres) ou MERGE (SQL Server) via SQL nativo quando performance e atomicidade no servidor forem requisitos. Em cargas massivas, avalie bibliotecas de bulk; elas geralmente vencem opções puramente EF por ordem de magnitude.

Checklist para decidir a estratégia

Antes de escolher, responda rápido:

  • Volume: milhares vs milhões?
  • Portabilidade: precisa rodar em Postgres e SQL Server?
  • Consistência: precisa de atomicidade total no banco?
  • Concorrência: múltiplos processos gravando os mesmos dados?

Com respostas em mãos: teste. Meça. Automatize. E prefira soluções que óbviomente falhem com erros fáceis de detectar e recuperar (retry, dead-letter, idempotência).

Próximos passos

Implemente um POC com sua carga típica: compare AddRange+SaveChanges chunked, ExecuteSqlRaw batched ON CONFLICT/MERGE e uma biblioteca bulk. Colete métricas de tempo, IOPS e uso de CPU. Use isso como base para a regra de produção.

Boa prática: documente a estratégia escolhida no repositório e inclua scripts de repro para o time poder validar mudanças no futuro.

Assuntos: C#, ef-core, aspnet-core, linq

Leia também