Skip to content

Bulk Operations

SaveChangesAsync() is the right default for normal business writes, but it is a poor fit for large imports, wide updates, or provider-specific upsert flows.

For those cases, the generated repository exposes bulk methods that bypass normal change tracking and execute provider-specific SQL.

Today, the generated concrete repository class is the primary entry point for bulk operations.

Typical methods on the generated repository:

public Task<int> BulkUpdateAsync(
ISpecification<TEntity> filter,
Action<UpdateSettersBuilder<TEntity>> updateAction,
CancellationToken ct = default);
public Task<int> BulkDeleteAsync(
ISpecification<TEntity> filter,
CancellationToken ct = default);
public Task<int> BulkInsertAsync(
IReadOnlyList<TEntity> entities,
BulkInsertOptions? options = null,
CancellationToken ct = default);
public Task<int> BulkUpsertAsync(
IReadOnlyList<TEntity> entities,
UpsertOptions? options = null,
CancellationToken ct = default);
public Task<int> UpsertAsync(
TEntity entity,
UpsertMatch matchOn = UpsertMatch.PrimaryKey,
CancellationToken ct = default);

These methods are generated onto the concrete repository — on every entity, not only some — so inject that type when you need them. There is no separate IBulkOperations<TEntity> service to resolve. The filter parameter is ISpecification<TEntity>; Spec<T>.Where(...) returns a Specification<T>, which implements it.

await products.BulkUpdateAsync(
Spec<Product>.Where(p => p.CategoryId == categoryId),
setters => setters.SetProperty(p => p.Price, p => p.Price * 1.1m),
ct);
await orders.BulkDeleteAsync(
Spec<Order>.Where(o => o.CreatedAt < cutoffDate),
ct);

If the entity uses [SoftDelete], the generated repository performs a soft-delete update rather than a physical delete.

await products.BulkInsertAsync(
importedProducts,
new BulkInsertOptions { BatchSize = 1000, CommandTimeout = 60 },
ct);
await products.BulkUpsertAsync(
externalProducts,
new UpsertOptions { MatchOn = UpsertMatch.LogicKey },
ct);

The generator emits a zero-reflection BulkEntityDescriptor<TEntity> inside the generated repository class:

private static readonly BulkEntityDescriptor<Order> _bulkDescriptor = new()
{
Columns =
[
("PersistenceId", BulkColumnRole.Key),
("OrderNumber", BulkColumnRole.LogicKey),
("Total", BulkColumnRole.Regular),
("CreatedAt", BulkColumnRole.InsertOnly),
("UpdatedAt", BulkColumnRole.UpdateOnly)
],
ReadValue = static (entity, prop) => prop switch
{
"PersistenceId" => entity.PersistenceId,
"OrderNumber" => entity.OrderNumber,
"Total" => entity.Total,
_ => null
}
};

That descriptor is what allows bulk operations to avoid runtime reflection.

RoleInsertUpsert updateTypical properties
KeyYesNoPersistenceId
LogicKeyYesMatch-onlySku, OrderNumber
RegularYesYesBusiness data columns
InsertOnlyYesNoCreatedAt, CreatedBy
UpdateOnlyNoYesUpdatedAt, UpdatedBy
SoftDeleteYesNoIsDeleted, DeletedAt, DeletedBy
ComputedNoNoconcurrency/computed columns
ProviderInsertUpsert
SQL Servermulti-row INSERTMERGE
PostgreSQLmulti-row INSERTINSERT ... ON CONFLICT DO UPDATE
SQLitemulti-row INSERTINSERT ... ON CONFLICT DO UPDATE

Keep provider-specific behavior covered by targeted tests, especially around upsert semantics.

ScenarioRecommended path
1-10 tracked entities in a normal business workflowAdd / Update / Remove plus SaveChangesAsync
Large import or sync jobBulkInsertAsync / BulkUpsertAsync
Wide update by predicateBulkUpdateAsync
Wide delete by predicateBulkDeleteAsync
Change tracking, domain hooks, normal aggregate behaviorNormal repository plus IUnitOfWork
  • Bulk operations bypass EF Core change tracking.
  • Interceptors do not run in the same way they do for normal tracked writes.
  • Audit/soft-delete metadata is handled by the generated bulk descriptor and bulk executor, not by normal tracked-save behavior.