Connect with me:
🚀 Sponsored
Struggling with slow EF Core operations? Boost performance like never before. Experience up to 14× faster Bulk Insert, Update, Delete, and Merge - and cut your save time by as much as 94%.
👉 entityframework-extensions.net
Introduction
Watch this: a foreach + SaveChangesAsync loop applying a price promotion to one category inside a million-row catalog - over 5 seconds and still going. Now the same promotion, same real data, done with a bulk call - 637 milliseconds, done. That’s the hook for this issue, and it’s also the trap: seeing that gap doesn’t tell you when you actually need the bulk version.
This is the sequel to the earlier Entity Framework Extensions video - same library, but this time two real scenarios on the same 1,000,000-row product catalog instead of an isolated benchmark: a tiered pricing promotion where the discount depends on each product’s current price and the tiers come from a per-category rules CSV, and importing a real CSV feed file the way a supplier or partner API would actually hand it to you. Both get measured as a well-written hand-rolled EF Core version against the bulk equivalent, same data, same machine, no thumb on the scale either way.
🎬 Watch the full video here:
⚙️ When You Don’t Need a Bulk Library At All
Start with the case people skip past: Electronics goes on sale, flat 10% off, every product in the category. The new price is one SQL expression, computable entirely in the database - no entities loaded, no bulk library needed:
await context.Products
.Where(p => p.CategoryId == electronicsCategoryId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 0.9m)
.SetProperty(p => p.UpdatedAtUtc, DateTime.UtcNow));Thousands of rows, one round trip, zero change tracking. The mental model worth keeping: if the new value is a straight SQL expression, ExecuteUpdateAsync is the right tool. BulkUpdate is not the default - it’s for when ExecuteUpdateAsync genuinely can’t express what you need.
The Real Complication: A Tiered Plan From a Spreadsheet
Real pricing rules rarely stay flat. The actual complication in this episode is a non-linear tiered discount - the percentage off depends on the current price bracket, and the tiers themselves differ per category. That’s not a hardcoded number, it’s a real spreadsheet a merchandising team would hand you: “Electronics under $20 gets 5% off, $20-$100 gets 10%, above that gets 15% - but Furniture’s tiers and percentages are different.” Not something you want buried in a SQL CASE expression.
The naive version does the honest thing EF Core gives you: load every tracked product in the category, change the price in a foreach, SaveChangesAsync. Against a category inside a million-row table, the change tracker detecting and translating every modified entity is exactly where those 5-plus seconds go.
This is where BulkUpdate earns its keep - pull a lightweight, no-tracking projection, compute the new price per row in C#, push the whole batch back in one call:
var targets = await context.Products
.AsNoTracking()
.Where(p => p.CategoryId == categoryId)
.Select(p => new Product { Id = p.Id, Price = p.Price })
.ToListAsync();
foreach (var product in targets)
product.Price = ApplyTier(product.Price, rule);
await context.BulkUpdateAsync(targets, options =>
{
options.ColumnPrimaryKeyExpression = p => p.Id;
options.ColumnInputExpression = p => new { p.Price };
});ColumnInputExpression matters here - it tells the library to only touch Price, so the rest of the row doesn’t get silently overwritten with whatever default values sat in that lightweight projection.
The Numbers, At Two Different Scales
Measured twice on purpose, because the multiplier isn’t a fixed number and pretending otherwise is how a benchmark gets picked apart in the comments. On one category, live from the intro: naive over 5 seconds, BulkUpdate 637 ms - the better part of 8x. Applied across the entire twenty-category, million-row catalog in one call each: plain tracked EF Core 51 seconds, BulkUpdateAsync 12.2 seconds - about 4.2x. The gap depends on scale and shape, and the only benchmark worth trusting is the one you run yourself.
One honest gotcha: the very first call after an app starts runs slower on both sides, from JIT and query-plan warm-up - don’t judge either number off the first hit.
Importing a Real CSV Feed: Naive vs BulkMerge
Second scenario, and this time nothing’s faked: a supplier drops a CSV export, or a partner API dumps a file, keyed by Sku - the business key both systems agree on - not your database’s Id. The naive version here isn’t a strawman: read the file, preload existing SKUs into a dictionary with one query, decide insert-vs-update per row, one SaveChangesAsync call at the end. That took 14.1 seconds.
BulkMerge collapses that entire decision into one call, keyed on the business key instead of the identity column:
await context.BulkMergeAsync(products, options =>
{
options.ColumnPrimaryKeyExpression = (Product p) => p.Sku;
options.IgnoreOnMergeUpdateExpression = (Product p) => new { p.Id, p.CreatedAtUtc };
});That IgnoreOnMergeUpdateExpression matters - without it, a feed row can stomp on columns it has no business touching, like your internal Id or the original CreatedAtUtc. Same feed, same work: 2.7 seconds end to end, 2.3 of them inside the database - almost 7x. But speed isn’t even the main argument here - plain EF Core has no built-in upsert operation at all. Preload-and-diff is the best you can hand-roll yourself; BulkMerge gets you the same result in one declarative call.
The Danger of BulkSynchronize
BulkSynchronize goes one step further than merge - anything in the table that isn’t present in the uploaded source gets deleted:
// Dangerous if the uploaded file is ever partial or empty:
await context.BulkSynchronizeAsync(incoming, options =>
{
options.ColumnPrimaryKeyExpression = (IncomingProduct p) => p.Sku;
});The demo hard-codes a guard against an empty input for exactly this reason - with nothing in the source, BulkSynchronize would empty the table. A partial file is the subtler version of the same failure: it deletes every real row that didn’t happen to be in the upload. If a real feed request ever timed out or came back truncated, that’s exactly what production would do.
The realistic version looks nothing like that. A plausible near-full nightly resync - a 950,000-row source file - reported through Z.BulkOperations.ResultInfo instead of one opaque count, comes back as 50,000 inserted, 900,000 updated, 100,000 deleted: over a million records reconciled in 18.7 seconds, three operations at once that EF Core has no combined equivalent for. Also worth locking down: IgnoreOnSynchronizeUpdateExpression, the BulkSynchronize counterpart to IgnoreOnMergeUpdateExpression above - without it, every synced row that matches an existing SKU gets its real CreatedAtUtc silently overwritten.
Reserve BulkSynchronize‘s real deletes for cases where you fully trust the completeness of every single sync run.
Where Else This Pattern Shows Up
The same two shapes - bulk-modify an existing table, or upsert-and-optionally-delete from an outside source - show up constantly outside a product catalog demo: nightly ETL and reporting loads, tax or rate recalculation across pending orders, IoT/telemetry ingestion, data backfills after adding a computed column, and seeding default data for a new tenant on signup. In every one of those, the real case for reaching for a library like this isn’t just the speed number - upsert and synchronize have no plain-EF-Core equivalent at all, and the alternative is hand-written, hand-maintained SQL MERGE per entity.
Connect with me:
Want to sponsor this newsletter? Let’s work together →
