Entity Framework Extensions offers much more than just faster bulk operations.
It provides a rich set of configuration options that allow you to control how data is inserted, updated, synchronized and tracked, making it easier to handle complex real-world scenarios.
In this article, we'll explore some of the most useful options and see how they can help you balance performance, flexibility and safety in your applications.
Starting Point
If you are new to the library, check out my previous blog post first: Getting Started with Entity Framework Extensions.
Here is the basic shape of most examples in this article:
using Z.EntityFramework.Extensions;
await dbContext.BulkInsertAsync(products, options =>
{
options.BatchSize = 2000;
});
Most options are configured in the lambda passed to the bulk method. You can apply the same idea to BulkInsert, BulkUpdate, BulkDelete, BulkMerge, BulkSynchronize and BulkSaveChanges.
For this blog post, we'll use a simple Product entity with a relationship to ProductReview:
public class Product
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public string Sku { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
public bool IsActive { get; set; }
public int Version { get; set; }
public List<ProductReview> Reviews { get; set; }
}
public class ProductReview
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public Product Product { get; set; }
public string Author { get; set; }
public string Content { get; set; }
public int Rating { get; set; }
public DateTime CreatedAt { get; set; }
}
Insert Behavior
The first group controls what happens during inserts. These options are useful when your input data does not map cleanly to the default database behavior.
await dbContext.BulkInsertAsync(products, options =>
{
options.InsertIfNotExists = true;
options.KeepIdentity = true;
options.AutoMapOutputDirection = false;
options.AutoTruncate = true;
});
InsertIfNotExists inserts only rows that are not already in the database. It is useful for imports where duplicate input can appear.
KeepIdentity lets you provide identity values yourself instead of letting the database generate them.
AutoMapOutputDirection controls whether database-generated values are mapped back to your entities. Setting it to false can make inserts much faster because the library does not need to return identity or computed values.
AutoTruncate automatically truncates string values to match the maximum length configured in your EF Core model. It can be helpful during imports but you should still validate important data before saving it.
If you only care about maximum insert speed and do not need output values, BulkInsertOptimized is often the simplest choice:
var analysis = await dbContext.BulkInsertOptimizedAsync(products);
Console.WriteLine(analysis.IsOptimized);
Console.WriteLine(analysis.TipsText);
It behaves like BulkInsert with AutoMapOutputDirection disabled and returns tips when one of your options prevents the fastest strategy.
Column And Key Options
The next group decides which columns participate in the operation and which columns identify a row.
await dbContext.BulkUpdateAsync(products, options =>
{
options.ColumnPrimaryKeyExpression = x => x.Sku;
options.ColumnInputExpression = x => new
{
x.Price,
x.Description,
x.ModifiedAt
};
});
ColumnPrimaryKeyExpression lets you match rows by a property other than the database primary key. This is common when an external system uses a SKU or another natural key.
ColumnInputExpression limits the columns written by the operation. It is one of the safest ways to avoid accidentally updating fields that should not be touched.
You can also exclude columns instead of listing the included ones:
await dbContext.BulkInsertAsync(products, options =>
{
options.IgnoreOnInsertExpression = x => new { x.CreatedAt };
});
await dbContext.BulkUpdateAsync(products, options =>
{
options.IgnoreOnUpdateExpression = x => new { x.CreatedAt };
});
When the columns are only known at runtime, use the string-based option ColumnPrimaryKeyNames:
dbContext.BulkInsert(products, options =>
{
options.ColumnPrimaryKeyNames = new List<string> { "Sku" };
});
There is also InsertPrimaryKeyFormula for advanced SQL-based matching during insert scenarios. Use it carefully because it is not compile-time safe and should never include untrusted user input.
Update Rules
Bulk operations become more interesting when source data and database data need to be merged carefully. These options help avoid overwriting good data with incomplete or stale values.
await dbContext.BulkMergeAsync(products, options =>
{
options.CoalesceExpression = x => new { x.Sku };
options.OnMergeUpdateUseCoalesceDestination = x => new { x.Description };
});
CoalesceExpression keeps the database value when the source value is null. OnMergeUpdateUseCoalesceDestination does the inverse, it updates the destination only when the database currently contains null.
For conditional updates, use matched options:
await dbContext.BulkUpdateAsync(products, options =>
{
options.UpdateMatchedAndConditionExpression = x => x.IsActive;
options.UpdateMatchedAndOneNotConditionExpression = x => new
{
x.Price,
x.Description
};
});
UpdateMatchedAndConditionExpression will only update the row if the IsActive value from the source matches the value in the database. UpdateMatchedAndOneNotConditionExpression updates only when at least one selected value is different.
When a rule is easier to express in SQL, use a matched formula option:
dbContext.BulkMerge(products, options =>
{
options.MergeMatchedAndFormula =
"StagingTable.Version > DestinationTable.Version";
});
The formula options include MergeMatchedAndFormula, UpdateMatchedAndFormula and SynchronizeMatchedAndFormula. They use StagingTable for incoming data and DestinationTable for the database table.
NOTE: Formula options execute raw SQL fragments. Do not concatenate user input into them.
Graph Options
When your entities have children, the IncludeGraph option lets the library process the full object graph in the right order. With it enabled, if your Product entities have Reviews populated, the bulk operation will process both in the correct order.
await dbContext.BulkMergeAsync(products, options =>
{
options.IncludeGraph = true;
options.IncludeGraphOperationBuilder = operation =>
{
if (operation is BulkOperation<Product> productOperation)
{
productOperation.ColumnPrimaryKeyExpression = x => x.Sku;
}
else if (operation is BulkOperation<ProductReview> reviewOperation)
{
reviewOperation.IsReadOnly = true;
}
};
});
Use graph configuration when each entity type needs different behavior. For example, a root Product can match by Sku, while a ProductReview can be marked with IsReadOnly to prevent updates.
You can use BulkOperationBuilder for this kind of per-entity configuration:
dbContext.BulkMerge(products, options =>
{
options.IncludeGraph = true;
options.BulkOperationBuilder = builder =>
{
builder.Entity<Product>().ColumnPrimaryKeyExpression = x => x.Sku;
builder.Entity<ProductReview>().ColumnPrimaryKeyExpression = x => x.Id;
};
});
Batching And Performance
Batch options control how much work is sent to the database at once.
await dbContext.BulkInsertAsync(products, options =>
{
options.BatchSize = 2000;
options.BatchTimeout = 180;
options.BatchDelayInterval = 100;
});
BatchSize controls the number of rows processed per batch. BatchTimeout controls how many seconds a batch can run before timing out. BatchDelayInterval adds a delay in milliseconds between batches.
Most of the time, the defaults are a good starting point. Tune these only when you see timeouts, memory pressure or database load issues.
Avoid BatchDelayInterval inside a transaction because it keeps locks open longer.
Synchronization Options
BulkSynchronize inserts missing rows, updates matching rows and deletes rows that are no longer present in the source collection.
That delete step is powerful, so it often needs a boundary:
await dbContext.BulkSynchronizeAsync(products, options =>
{
options.SynchronizeKeepIdentity = true;
options.ColumnSynchronizeDeleteKeySubsetExpression = x => x.Category;
});
ColumnSynchronizeDeleteKeySubsetExpression limits which destination rows can be deleted during synchronization. For example, if you synchronize only one product category, products in other categories should not disappear from the database.
Observability Options
Bulk operations are fast, but production systems still need visibility. Entity Framework Extensions includes options for audit entries, generated SQL and affected row counts.
var auditEntries = new List<AuditEntry>();
var resultInfo = new ResultInfo();
await dbContext.BulkMergeAsync(products, options =>
{
options.UseAudit = true;
options.AuditEntries = auditEntries;
options.ResultInfo = resultInfo;
options.Log = message => Console.WriteLine(message);
});
UseAudit and AuditEntries capture what changed.
Log captures SQL and execution details as the operation runs. ResultInfo gives you affected, inserted, updated and deleted row counts.
If you want to collect all logs and inspect them after the operation, use UseLogDump and LogDump:
var logBuilder = new StringBuilder();
dbContext.BulkInsert(products, options =>
{
options.UseLogDump = true;
options.LogDump = logBuilder;
});
Auditing and logging are extremely useful while debugging imports, but they can add overhead. Enable them intentionally.
Events and Transactions
Events let you plug into the bulk operation lifecycle. They are useful for audit fields, validation, logging or last-minute configuration.
EntityFrameworkManager.PreBulkInsert = (ctx, entities) =>
{
if (entities is IEnumerable<Product> products)
{
foreach (var product in products)
{
product.CreatedAt = DateTime.UtcNow;
}
}
};
Common lifecycle hooks include PreBulkInsert, PostBulkInsert, PreBulkUpdate, PostBulkUpdate, PreBulkDelete, PostBulkDelete, PreBulkMerge, PostBulkMerge, PreBulkSynchronize and PostBulkSynchronize.
For operation-level hooks, use PostConfiguration, BulkOperationExecuting and BulkOperationExecuted:
dbContext.BulkSaveChanges(options =>
{
options.BulkOperationExecuting = operation =>
{
Console.WriteLine("Bulk operation starting...");
};
options.BulkOperationExecuted = operation =>
{
Console.WriteLine("Bulk operation completed.");
};
});
For transactions, remember one practical rule. BulkSaveChanges runs inside a transaction like SaveChanges. Direct bulk methods such as BulkInsert, BulkUpdate, BulkDelete and BulkMerge should be wrapped in your own transaction when multiple operations must succeed or fail together.
await using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
await dbContext.BulkInsertAsync(products);
await dbContext.BulkUpdateAsync(productReviews);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
Future Actions
Bulk methods execute immediately by default. If you want to queue multiple operations and run them later, use FutureAction and ExecuteFutureAction.
dbContext.FutureAction(x => x.BulkInsert(productReviews));
dbContext.FutureAction(x => x.BulkInsert(products));
dbContext.ExecuteFutureAction();
This is useful when you want to prepare several bulk operations in one place and execute them as a group.
Conclusion
Entity Framework Extensions is fast out of the box, but the real value comes from how configurable it is.
Start with the default behavior, then introduce additional options as your use cases become more complex.
Understanding these options will help you build bulk processing workflows that are not only fast, but also reliable, maintainable, and production-ready.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
