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:
- Fazer operações em janelas menores (chunking).
- Usar tabelas de staging e aplicar transformações em horários off-peak.
- 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
EF Core + Dapper: padrão híbrido (Read/Write) para consultas de alta performance
Padrão híbrido: use EF Core para writes e Dapper para reads. Mostro código real — repositórios, transações compartilhadas, queries parametrizadas e paginação…
Importação massiva de CSV com Npgsql COPY em Minimal APIs e EF Core
Como projetar importação massiva de CSV com Npgsql COPY em Minimal APIs + EF Core: estratégias, exemplos práticos, staging, upsert e debugging para pipelines…
JWT em Minimal APIs (ASP.NET Core): access e refresh tokens do zero
Aprenda do zero como implementar access e refresh tokens com JWT em Minimal APIs do ASP.NET Core: código real, armazenamento de refresh token, endpoints…
Multitenancy em ASP.NET Core com EF Core: discriminator, schema e database
Comparação prática entre discriminator, schema e database para multitenancy em ASP.NET Core com EF Core. Implementação de TenantProvider, DbContext dinâmico…
Soft delete no EF Core: filtros globais, auditoria e restauração
Soft delete com EF Core evita perda de dados, permite auditoria e restauração. Aprenda o jeito errado (filtros manuais) e o jeito certo (global query filters…
Consultas dinâmicas seguras com Expression Trees e Specification no EF Core
Como construir consultas dinâmicas seguras no EF Core usando Expression Trees e Specification: padrões, código pronto e soluções para problemas de tradução e…