EF Core bulk insert: o guia completo para inserir milhares de registros sem degradar a performance

Fazer EF Core bulk insert de forma eficiente é um desafio comum, porque o código ingênuo funciona até o volume crescer. Neste guia, você vê as abordagens do EF Core 10, do padrão nativo até bibliotecas especializadas.

Todo desenvolvedor .NET já escreveu alguma variação deste código:

foreach (var product in incomingProducts)
{
    context.Products.Add(product);
    await context.SaveChangesAsync();
}

O problema não aparece durante o desenvolvimento. Com 200 registros no banco local, a operação termina em milissegundos, os testes passam e o código segue para produção.

Então, o mesmo código precisa processar 300 mil registros no job noturno de importação e leva horas para concluir.

Por que o EF Core bulk insert ingênuo fica lento

O loop com SaveChangesAsync dentro falha em produção por três razões que se somam. Nenhuma delas fica evidente até alguém medir.

O custo do change tracker

O primeiro problema é o change tracker. Cada chamada a SaveChangesAsync executa DetectChanges(), que percorre todas as entidades rastreadas pelo contexto.

Como o contexto continua rastreando tudo o que já salvou, cada iteração do loop varre uma lista maior que a anterior. Por isso, o custo total do loop cresce de forma quadrática em relação ao número de registros.

Com 100 entidades, o impacto passa despercebido. Com 100 mil, no entanto, esse EF Core bulk insert ingênuo gasta a maior parte do tempo detectando mudanças.

Vale corrigir um mito comum aqui. No EF Core, Add e AddRange não chamam DetectChanges automaticamente, e a documentação oficial afirma que não há diferença significativa de performance entre os dois.

Esse comportamento de chamar DetectChanges a cada Add pertencia ao Entity Framework 6. Assim, no EF Core, o vilão do loop é o SaveChanges repetido, e não o Add.

Round-trips e pressão de memória

O segundo problema é a quantidade de round-trips. Cada SaveChangesAsync() dentro do loop envia um INSERT e espera a confirmação do banco antes de seguir.

Trezentos mil registros resultam em trezentos mil idas e voltas sequenciais. Mesmo em redes internas de nuvem com latência baixa, essa conta fica muito lenta.

O terceiro problema é a pressão sobre a memória gerenciada. Cada entidade adicionada permanece no change tracker enquanto o contexto viver, junto com o snapshot dos seus valores.

Em volumes altos, isso significa muita memória alocada no heap e mais trabalho para o coletor de lixo.

Cada um desses problemas tem solução. Contudo, as soluções ficam mais poderosas e também mais invasivas à medida que avançamos.

EF Core bulk insert nativo com AddRange

Antes de recorrer a qualquer biblioteca externa, vale entender o que o EF Core 10 oferece nativamente. A maioria dos desenvolvedores subestima o quanto esse comportamento melhorou nas versões recentes.

Adicionar todas as entidades e chamar SaveChanges() uma única vez resolve boa parte dos cenários de EF Core bulk insert.

var products = LoadProductsFromCsv(filePath);

// adiciona tudo e salva em uma única chamada
context.Products.AddRange(products);
await context.SaveChangesAsync();

Com esse padrão, DetectChanges() roda uma única vez, no início do SaveChanges. Em seguida, o EF Core agrupa os comandos em batches e envia vários registros em cada round-trip.

Desde o EF Core 7, o provider do SQL Server também gera um SQL mais enxuto para inserções múltiplas. Ele usa um único MERGE com cláusula OUTPUT para devolver as chaves geradas, e a Microsoft relata ganhos de até quatro vezes em relação ao EF Core 6.

Na prática, o change tracker continua funcionando normalmente. Ou seja, após o SaveChanges, cada entidade em memória já tem o valor de identidade que o banco gerou.

Limites de batch no SQL Server

Existe um limite que merece atenção. O provider do SQL Server executa por padrão até 42 comandos por batch, porque a documentação indica que o ganho do batching cai depois de cerca de 40 comandos.

Além disso, o SQL Server aceita no máximo 2.100 parâmetros por requisição. Como cada coluna conta como um parâmetro por linha, entidades com muitas colunas reduzem o tamanho efetivo de cada batch.

Você pode ajustar esses limites com MinBatchSize e MaxBatchSize. Porém, a própria documentação recomenda medir com cuidado antes de mudar os valores padrão.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer(
        connectionString,
        options => options
            .MinBatchSize(1)
            .MaxBatchSize(100)); // altere só depois de medir
}

EF Core bulk insert em lotes

Quando o volume é grande demais para manter tudo no change tracker, uma técnica nativa ajuda bastante. Você divide a carga em lotes, salva cada lote e limpa o rastreamento com ChangeTracker.Clear().

const int chunkSize = 5_000;

foreach (var chunk in products.Chunk(chunkSize))
{
    context.Products.AddRange(chunk);
    await context.SaveChangesAsync();

    // libera as entidades já salvas do change tracker
    context.ChangeTracker.Clear();
}

Dessa forma, o contexto nunca rastreia mais do que um lote por vez. Assim, o EF Core bulk insert mantém a memória estável e o DetectChanges não cresce ao longo da importação.

Quando o padrão nativo não basta

O EF Core bulk insert nativo tem quatro limitações reais. Primeiro, a pressão de memória em volumes muito altos sem divisão em lotes.

Depois, vem a perda de eficiência em entidades largas, por causa do limite de parâmetros. Também falta suporte nativo para inserção condicional, em que apenas linhas inexistentes entram no banco.

Por fim, há o custo em grafos pai e filho de alto volume, porque o EF Core precisa das chaves dos pais antes de inserir os filhos.

SqlBulkCopy: a alternativa de mais baixo nível

O SqlBulkCopy é o caminho direto para o protocolo de bulk copy do SQL Server. Ele dispensa o EF Core no bulk insert e transmite os dados em formato binário otimizado.

Não há instruções INSERT individuais nem binding de parâmetros por linha. Assim, o conjunto inteiro trafega em uma operação contínua.

var table = new DataTable();
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Sku", typeof(string));
table.Columns.Add("Price", typeof(decimal));

foreach (var p in products)
    table.Rows.Add(p.Name, p.Sku, p.Price);

await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction();

using var bulk = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, transaction)
{
    DestinationTableName = "Products",
    BatchSize = 5_000
};

bulk.ColumnMappings.Add("Name", "Name");
bulk.ColumnMappings.Add("Sku", "Sku");
bulk.ColumnMappings.Add("Price", "Price");

await bulk.WriteToServerAsync(table);
await transaction.CommitAsync();

O código funciona. No entanto, ele também cria um passivo de manutenção que cedo ou tarde aparece.

Os custos escondidos do SqlBulkCopy

O DataTable é uma cópia manual do esquema do banco. Por isso, renomear uma coluna em uma migration ou adicionar um campo pode quebrar esse código sem nenhum aviso em tempo de compilação.

Além disso, o modelo do EF Core não existe nesse contexto. Value converters, owned types e shadow properties ficam de fora, e você precisa replicar essas regras à mão.

Depois da operação, as entidades em memória também não recebem os valores de identidade. Para inserir filhos que referenciam esses pais, você precisa de um SELECT extra, da atribuição manual das chaves estrangeiras e de uma segunda carga.

O SqlBulkCopy atende apenas o SQL Server. Para PostgreSQL, SQLite, MySQL ou Oracle, o código muda por completo, e no PostgreSQL o equivalente nativo é o comando COPY.

Há ainda um comportamento que surpreende quem vem do EF Core. Segundo a documentação do SqlBulkCopyOptions, o padrão não verifica constraints e não dispara triggers de inserção.

Os dois comportamentos dependem de opt-in, com SqlBulkCopyOptions.CheckConstraints e SqlBulkCopyOptions.FireTriggers. Além disso, sem TableLock, a operação usa locks de linha.

Na prática, o SqlBulkCopy brilha em tabelas de staging de ETL com entidades simples. Ele também atende equipes que dispensam o retorno de identidade e aceitam o mapeamento manual.

Bibliotecas de EF Core bulk insert

Entre o SaveChanges nativo e o SqlBulkCopy puro, existem bibliotecas especializadas em EF Core bulk insert. A mais conhecida é a Entity Framework Extensions (EFE), da ZZZ Projects.

A EFE encapsula o mecanismo de bulk de cada banco em uma API integrada aos modelos, mapeamentos e convenções do EF Core. Segundo o fabricante, ela suporta SQL Server, PostgreSQL, MySQL, MariaDB, SQLite e Oracle.

O resultado prático é um throughput próximo ao do SqlBulkCopy, mas com a experiência do EF Core. Ou seja, você dispensa o DataTable e o mapeamento manual de colunas.

A EFE é uma biblioteca comercial paga, com período de avaliação. Por isso, as próximas seções mostram onde ela compensa e onde o EF Core nativo ou o SqlBulkCopy bastam.

BulkSaveChanges: o caminho de menor risco

Antes do BulkInsert, a EFE oferece algo que muitas equipes ignoram. O BulkSaveChanges substitui o SaveChanges() e envia as mudanças do change tracker pelo mecanismo de bulk.

context.Products.AddRange(products);

// mesma semântica do SaveChanges, com envio via bulk
await context.BulkSaveChangesAsync();

Esse é o caminho de migração com menor risco para um EF Core bulk insert. O código que já usa o change tracker continua igual, então a refatoração fica mínima.

Para bases de código grandes, com chamadas a SaveChanges() espalhadas pelo projeto, essa costuma ser a primeira mudança a testar.

BulkInsert e BulkInsertOptimized

Quando o objetivo é pular o change tracker por completo, o BulkInsert é o passo seguinte.

await context.BulkInsertAsync(products);

Não há AddRange() nem SaveChanges(). A operação grava a coleção inteira imediatamente e retorna.

Há um detalhe importante aqui. Como a chamada executa na hora, ela não participa do commit diferido do SaveChanges, então uma falha posterior não desfaz esse insert.

Se a atomicidade importa, envolva toda a sequência em uma transação explícita com context.Database.BeginTransactionAsync().

A opção AutoMapOutputDirection = false evita o passo que devolve os valores gerados pelo banco para as entidades. O atalho para esse comportamento é o BulkInsertOptimizedAsync(), recomendado quando você não precisa das identidades.

await context.BulkInsertOptimizedAsync(products);

Opções que realmente importam

A EFE disponibiliza dezenas de opções, mas poucas cobrem a maioria dos casos. Com InsertIfNotExists, a biblioteca insere apenas as linhas que ainda não existem, com base em uma chave definida por você.

await context.BulkInsertAsync(products, options =>
{
    options.InsertIfNotExists = true;
    // usa o SKU como chave para checar existência
    options.ColumnPrimaryKeyExpression = p => new { p.Sku };
});

O InsertKeepIdentity = true grava os valores de identidade que vêm da origem, algo essencial em migrações que precisam preservar IDs antigos.

Já o UseTableLock = true adquire um lock de tabela durante a carga. Isso acelera inserções grandes, mas bloqueia leituras e escritas concorrentes enquanto a operação durar.

Por fim, entidades com colunas que têm valor padrão no banco podem se comportar de forma diferente. Nesse caso, a fonte original recomenda ExplicitValueResolutionMode.Smart para alinhar a EFE ao EF Core.

Interceptors e auditoria no EF Core bulk insert

Um aviso merece destaque. De acordo com a fonte original deste artigo, as operações de bulk da EFE não disparam implementações de ISaveChangesInterceptor.

Se a auditoria ou o soft delete vivem em um interceptor, esses hooks não rodam no BulkInsert nem nos demais métodos de bulk.

Portanto, revise os interceptors antes de adotar qualquer EF Core bulk insert via biblioteca. Depois, mova essa lógica para triggers, para a camada de aplicação ou para a auditoria da própria biblioteca.

Alternativa com outra licença

Outra opção popular é a EFCore.BulkExtensions, mantida por borisdj. Ela oferece BulkInsert, BulkInsertOrUpdate, BulkSaveChanges e outros métodos para SQL Server, PostgreSQL, MySQL, Oracle e SQLite.

Embora o código fique no GitHub, a biblioteca adota hoje uma licença dupla. O uso é gratuito para quem cumpre os critérios da licença comunitária, e os demais precisam de licença comercial.

EF Core bulk insert de grafos com IncludeGraph

Até aqui, tratamos de listas simples. Aplicações reais, porém, raramente têm dados simples, porque pedidos têm itens e faturas têm linhas.

Considere 10 mil pedidos, cada um com cinco itens em média. São 60 mil linhas para inserir com integridade referencial preservada.

Com AddRange e SaveChangesAsync(), o EF Core resolve a ordem sozinho. Ele insere os pedidos, recebe as identidades via OUTPUT, preenche o OrderId de cada item e então insere os itens.

Em volumes muito altos, essa sequência vira o gargalo do EF Core bulk insert. Já com SqlBulkCopy, o processo em dois passos é manual e frágil.

A opção IncludeGraph da EFE resolve isso em uma única chamada.

await context.BulkInsertAsync(orders, options =>
    options.IncludeGraph = true);

A biblioteca percorre as navegações, ordena a inserção pelas chaves estrangeiras e propaga as identidades dos pais.

Três cuidados valem antes de usar em produção. Primeiro, desabilite o lazy loading, porque a travessia das navegações pode carregar muito mais dados do que você pretende.

Segundo, as colunas de identidade precisam vir do banco ou da opção InsertKeepIdentity. Terceiro, o aviso sobre interceptors também vale para todos os tipos do grafo.

Comparando as abordagens de EF Core bulk insert

A fonte original apresenta tempos de benchmark, mas ela própria trata esses números como padrões relativos. Por isso, a tabela abaixo resume as diferenças qualitativas entre as abordagens de EF Core bulk insert.

Abordagem Change tracker Retorna identidade Bancos suportados Custo de manutenção
Loop com SaveChanges Sim Sim Todos os providers Baixo, mas lento
AddRange + SaveChanges Sim Sim Todos os providers Baixo
SqlBulkCopy Não Não Somente SQL Server Alto
BulkInsert (EFE) Não Sim, por padrão Vários providers Baixo, com licença paga
BulkInsertOptimized (EFE) Não Não Vários providers Baixo, com licença paga

Os padrões relatados pela fonte são consistentes. O AddRange com SaveChanges cresce de forma linear, enquanto o SqlBulkCopy fica cerca de uma ordem de grandeza à frente.

O BulkInsert fica próximo do SqlBulkCopy, e a diferença cobre o retorno das identidades. Em grafos, o IncludeGraph também supera o processo manual em dois passos, porque elimina o SELECT intermediário.

Em qualquer caso, meça o EF Core bulk insert no seu ambiente. O BenchmarkDotNet ajuda a comparar as opções com o seu esquema e o seu volume.

Se você ainda está decidindo entre ORMs, o comparativo de performance entre EF Core e Dapper complementa essa análise.

Como escolher a estratégia de EF Core bulk insert

O guia a seguir cobre os cenários mais comuns de EF Core bulk insert de forma direta.

Para inserções simples e ocasionais de poucos milhares de linhas, AddRange com uma única chamada a SaveChangesAsync() resolve.

Em importações maiores sem bibliotecas externas, combine AddRange, SaveChangesAsync() e ChangeTracker.Clear() em lotes.

Se a base de código tem SaveChanges() espalhado e pouco tempo de refatoração, teste primeiro o BulkSaveChangesAsync().

Para cargas grandes de entidades simples com retorno de identidade, use BulkInsertAsync(). Se a identidade não importa, então o BulkInsertOptimizedAsync() entrega mais velocidade.

Em grafos pai e filho com volume significativo, use BulkInsertAsync() com IncludeGraph = true. Já para inserção condicional, use InsertIfNotExists, e para upsert completo, use o BulkMerge.

Para staging de ETL exclusivo de SQL Server, sem retorno de identidade, o SqlBulkCopy continua uma escolha sólida.

Cuidados com EF Core bulk insert em produção

Alguns comportamentos surpreendem equipes em ambientes reais. O primeiro é que o BulkInsert executa na hora, fora do SaveChanges, então use uma transação explícita quando precisar de atomicidade.

O segundo envolve o BulkInsertOptimized. Depois dele, as entidades em memória não têm as identidades, e filhos inseridos em seguida podem receber chaves estrangeiras zeradas.

Nesse caso, use IncludeGraph para tratar o grafo inteiro ou recarregue os pais após a inserção.

O terceiro cuidado envolve locks. Um EF Core bulk insert com UseTableLock ou TableLock bloqueia leituras e escritas concorrentes, portanto prefira locks de linha em sistemas ativos.

Por fim, tabelas com triggers mudam o SQL que o EF Core gera no SQL Server, como explica a documentação de providers do SQL Server.

Conclusão

Fazer EF Core bulk insert com eficiência é um problema resolvido, mas a solução certa depende das restrições de cada cenário.

O ponto de partida é sempre AddRange com uma única chamada a SaveChangesAsync(). O batching nativo do EF Core 10 atende bem cargas moderadas, sem dependências externas e com retorno de identidades.

Quando o volume cresce, a divisão em lotes com ChangeTracker.Clear() mantém a memória sob controle. Em seguida, BulkSaveChanges, BulkInsert e BulkInsertOptimized oferecem throughput próximo ao SqlBulkCopy sem abrir mão do modelo do EF Core.

Por fim, em um EF Core bulk insert de grafos, o IncludeGraph elimina a parte mais frágil: o cabeamento manual das chaves estrangeiras.

FAQ

AddRange é mais rápido que Add em loop no EF Core?

Não de forma significativa, segundo a documentação oficial, porque nenhum dos dois chama DetectChanges no EF Core. O ganho real vem de chamar SaveChanges uma única vez, fora do loop.

Quando o EF Core nativo é suficiente para inserção em massa?

Para a maioria das aplicações, AddRange com SaveChangesAsync() atende cargas moderadas. Em volumes altos ou entidades muito largas, vale dividir em lotes ou avaliar bibliotecas de bulk.

BulkInsert substitui completamente o SaveChanges?

Não. O BulkInsert executa imediatamente, fora do change tracker, e exige transação explícita para garantir atomicidade.

SqlBulkCopy funciona com PostgreSQL?

Não. O SqlBulkCopy é exclusivo do SQL Server, e no PostgreSQL o equivalente nativo é o comando COPY.

IncludeGraph é seguro com lazy loading habilitado?

Não. A travessia das navigation properties pode disparar carregamentos silenciosos, então desabilite o lazy loading antes de usar o IncludeGraph.

Interceptors de auditoria funcionam com operações de bulk?

Segundo a fonte original, as operações de bulk da EFE não disparam ISaveChangesInterceptor. Revise os interceptors antes de adotar essas operações.

Como fazer upsert em massa com EF Core?

O InsertIfNotExists cobre apenas a inserção condicional. Para inserir novos e atualizar existentes, use o BulkMerge da EFE ou o BulkInsertOrUpdate da EFCore.BulkExtensions.