How I Increased a Production Payment System's Performance by 15x With EF Core Extensions
A true-to-life story of making a slow EF Core payment system 15x faster using bulk extensions, ExecuteUpdate, and ExecuteDelete with simple examples.
Picture a busy railway ticket counter in India during a festival rush. One clerk serves people one by one. He takes a form, checks it, prints one ticket, hands it back, then calls the next person. The line never seems to move. The crowd grows. Everyone is frustrated.
Now imagine the station opens a counter that takes the whole group's forms in one stack, processes them together in a single batch, and hands back all the tickets at once. The same number of tickets get printed, but the line clears in minutes instead of hours.
This is the exact story of how I made a real payment system run 15 times faster. The slow part was not the bank. It was the way our code talked to the database. We were processing payments one by one, like that single clerk. Once we switched to bulk operations in Entity Framework Core (EF Core), the line cleared. Let me show you how.
The system that was crawling
Our payment system did one main job every night. It took a big file from a payment provider. The file had thousands of payment records. Some were brand-new payments. Some were updates to old payments (like "this payment is now settled"). And some old, failed payments had to be deleted.
A normal nightly batch had around 80,000 rows. On a busy day it crossed 200,000 rows. The old code looked clean and correct. But it was painfully slow. A single batch took around 11 minutes. During festivals it sometimes took over half an hour, and other jobs had to wait.
Here is the simplified version of what the old code did.
// The OLD way - one row at a time
foreach (var record in incomingPayments)
{
var payment = new Payment
{
Reference = record.Reference,
Amount = record.Amount,
Status = "Pending",
CreatedAt = DateTime.UtcNow
};
dbContext.Payments.Add(payment);
await dbContext.SaveChangesAsync(); // called inside the loop!
}Can you spot the problem? SaveChangesAsync is called inside the loop. For 80,000 rows, that means 80,000 separate trips to the database. Each trip is small, but the travel time adds up. It is like the clerk walking back and forth for every single ticket.
Why SaveChanges gets slow
To understand the fix, you need to know what EF Core does behind the scenes. EF Core keeps a change tracker. Think of it as a notebook. Every object you add, edit, or delete gets written in this notebook. When you call SaveChanges, EF Core reads the notebook and creates SQL for each change.
For a few hundred rows, the notebook is fine. But for 80,000 rows, the notebook becomes huge. EF Core has to scan it, build commands, and talk to the database again and again. Memory fills up. The database does a lot of tiny work instead of one big efficient piece of work.
Each arrow to the database is a round trip. A round trip has a fixed cost: open the connection, send the command, wait, get the answer. When you do this 80,000 times, the fixed cost is what kills you. It is not the work itself. It is all the back-and-forth.
First fix: stop saving inside the loop
The very first improvement is simple and free. Move SaveChanges outside the loop. Add everything to the change tracker, then save once. This alone cut our time a lot, because EF Core can batch many inserts into fewer commands.
// Better - add everything, then save once
foreach (var record in incomingPayments)
{
dbContext.Payments.Add(new Payment
{
Reference = record.Reference,
Amount = record.Amount,
Status = "Pending",
CreatedAt = DateTime.UtcNow
});
}
await dbContext.SaveChangesAsync(); // called ONCE, outside the loopThis was better, but not enough. The change tracker still held all 80,000 objects in memory. The job still took around 6 minutes. We needed to go further.
The big jump: bulk operations
Here is where the real speed came from. We brought in two tools.
- EFCore.BulkExtensions for inserting and updating many rows fast.
- ExecuteUpdate and ExecuteDelete, which are built right into EF Core, for set-based changes.
Let me explain each one with a clear example.
BulkInsert for new payments
BulkInsert skips the change tracker completely. It uses the database's own fast copy method (like SqlBulkCopy on SQL Server) to push thousands of rows in one smooth stream. No notebook. No per-row commands.
using EFCore.BulkExtensions;
var newPayments = incomingPayments
.Select(record => new Payment
{
Reference = record.Reference,
Amount = record.Amount,
Status = "Pending",
CreatedAt = DateTime.UtcNow
})
.ToList();
// One fast bulk operation instead of 80,000 inserts
await dbContext.BulkInsertAsync(newPayments);The difference is huge. Instead of thousands of tiny trips, the database gets one big, well-organised delivery. In benchmarks, BulkInsert is often 5x to 8x faster than AddRange plus SaveChanges, and uses far less memory.
ExecuteUpdate for status changes
Many rows in the file were not new. They were updates. For example, "mark every pending payment older than 7 days as expired." The old code loaded each matching row, changed it, and saved it. That meant pulling thousands of rows into memory just to flip one field.
ExecuteUpdate fixes this. It turns a LINQ query straight into one SQL UPDATE statement. No rows are loaded. No change tracking. The database does the whole job in one shot.
// Update many rows WITHOUT loading them into memory
await dbContext.Payments
.Where(p => p.Status == "Pending"
&& p.CreatedAt < DateTime.UtcNow.AddDays(-7))
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Status, "Expired")
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow));This single call replaces a loop that might have touched thousands of rows. Microsoft's own docs and community benchmarks show ExecuteUpdate can be hundreds of times faster than the load-then-save pattern on big updates.
ExecuteDelete for cleanup
The same idea works for deletes. We had to remove old failed payments after they were archived. The old code loaded them all, then called RemoveRange, then SaveChanges. Wasteful.
ExecuteDelete turns a Where clause into one SQL DELETE. Nothing is loaded.
// Delete matching rows directly in the database
var cutoff = DateTime.UtcNow.AddMonths(-6);
await dbContext.Payments
.Where(p => p.Status == "Failed" && p.CreatedAt < cutoff)
.ExecuteDeleteAsync();Putting the whole pipeline together
Once we combined all three tools, the nightly job flowed in clear stages. Each stage did one job, in bulk.
New nightly payment pipeline
Steps
Read file
Load provider file into memory once
Bulk insert
BulkInsert all brand-new payments
Bulk update
ExecuteUpdate to change statuses
Bulk delete
ExecuteDelete old failed rows
Done
Commit and log results
This pipeline replaced a slow, row-by-row job with a few big, smart operations. The whole batch dropped from around 11 minutes to about 44 seconds. That is the 15x improvement in the title, measured on real production data.
Decision: which tool to pick
Steps
Inserting?
Use BulkInsert for many new rows
Updating fields?
Use ExecuteUpdate, no loading
Deleting?
Use ExecuteDelete by condition
Small set?
Plain SaveChanges is fine
The numbers, side by side
Here is the before-and-after for a typical 80,000-row batch. These are rounded figures from our own tests, similar to public benchmarks.
| Operation | Old way | New way | Roughly faster |
|---|---|---|---|
| Insert new payments | 5 min 30 s | 22 s | ~15x |
| Update statuses | 3 min 10 s | 8 s | ~24x |
| Delete old rows | 2 min 20 s | 6 s | ~23x |
| Full nightly batch | 11 min | 44 s | ~15x |
Memory use dropped just as sharply, because the change tracker was no longer holding tens of thousands of objects.
| Approach | Memory for 100k rows | Notes |
|---|---|---|
| Add one-by-one | ~38 MB | Slowest, biggest notebook |
| AddRange + SaveChanges | ~28 MB | Better batching |
| BulkInsert | ~18 MB | Smallest, fastest |
The trade-offs you must know
Bulk operations are powerful, but they are not magic. They skip the change tracker on purpose. That speed comes with rules you must respect.
- No change tracking. EF Core does not "see" the rows you touched. If your app relies on tracked entities right after, you must reload them.
- Interceptors and audit logic may not run. Many teams hook into
SaveChangesto write audit logs or set timestamps.ExecuteUpdateandExecuteDeleteskip that, so set those fields yourself inside the call. - Global query filters do not apply to the update or delete predicate. If you use soft-delete filters, write the condition by hand.
- Validation is your job. Bulk inserts do not run your normal validation hooks. Clean the data before you push it.
We handled audit by writing the UpdatedAt field directly in the ExecuteUpdate call, and by writing one summary audit row after each stage instead of one per payment. That kept the audit trail honest without slowing things down.
Keep it safe with a transaction
When you run several bulk steps in a row, you want them to all succeed or all fail together. Wrap them in a transaction. If the delete fails halfway, the insert should roll back too. This keeps your payment data consistent, which matters a lot when money is involved.
await using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
await dbContext.BulkInsertAsync(newPayments);
await dbContext.Payments
.Where(p => p.Status == "Pending" && p.IsConfirmed)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Status, "Settled"));
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}A note on libraries and licensing
There is some confusion in the community, so let me be clear. EFCore.BulkExtensions (by borisdj on GitHub) is open source with a free community edition, and it supports SQL Server, PostgreSQL, MySQL, SQLite, and Oracle. It is updated for EF Core 10 on .NET 10, the current LTS release.
There is a separate, paid product called Entity Framework Extensions (the Z.EntityFramework family). It is commercial. Do not mix the two up. For most teams, the free EFCore.BulkExtensions plus the built-in ExecuteUpdate and ExecuteDelete cover almost every need without paying anything.
Also worth knowing: ExecuteUpdate and ExecuteDelete are part of EF Core itself. They need no extra package. EF Core 10 made them smarter, including better support for updating with values from related tables.
When NOT to use bulk operations
Bulk tools shine on large batches. For small work, plain SaveChanges is simpler, safer, and gives you full change tracking, validation, and interceptors. A good rule: if you are touching fewer than a few thousand rows, stay with SaveChanges. Reach for bulk operations when the row count climbs into the tens of thousands and you can feel the slowness.
Do not reach for bulk just to look clever. Reach for it because a real measurement told you the old way was too slow. We only changed our code after we measured 11 minutes and knew it had to come down. Measure first. Then optimise.
References and further reading
- EFCore.BulkExtensions on GitHub - the open-source bulk library.
- Microsoft Learn: ExecuteUpdate and ExecuteDelete - official docs for built-in bulk changes.
- Microsoft Learn: EF Core overview - the home page for EF Core.
- Bulk Operations in EF Core 10 (benchmarks) - community benchmarks for insert, update, and delete.
- What You Need To Know About EF Core Bulk Updates - clear write-up on ExecuteUpdate.
Quick recap
- The slow code saved payments one row at a time, causing thousands of database round trips.
- The first easy win was moving
SaveChangesoutside the loop. BulkInsertfrom EFCore.BulkExtensions inserts many rows in one fast stream, skipping the change tracker.ExecuteUpdateturns a LINQ query into one SQLUPDATEwith no rows loaded.ExecuteDeleteturns a condition into one SQLDELETE, also without loading rows.- Together these cut a nightly batch from 11 minutes to 44 seconds, about 15x faster, with far less memory.
- Bulk operations skip change tracking, interceptors, validation, and global filters, so handle audit and timestamps yourself.
- Wrap multiple steps in a transaction so they all succeed or all roll back.
- Use bulk for large batches; keep plain
SaveChangesfor small ones. - Measure first, then optimise.
Related Posts
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
The Correct Way to Use Batch Update and Batch Delete in EF Core
Learn the correct, safe way to use ExecuteUpdate and ExecuteDelete batch methods in EF Core, with transactions, change tracker tips, and EF Core 10 features.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
How to Use the New Bulk Update Feature in EF Core 7
Learn EF Core 7 bulk updates with ExecuteUpdate and ExecuteDelete. Update or delete many rows in one fast SQL trip, no entity loading needed.
What You Need to Know About EF Core Bulk Updates
A friendly guide to EF Core bulk updates with ExecuteUpdate and ExecuteDelete. Change many rows in one fast SQL trip, plus the traps to avoid.
Optimizing Bulk Database Updates in .NET: A Practical Guide
Learn how to make bulk database updates fast in .NET using EF Core ExecuteUpdate, batching, and transactions, with simple examples and EF Core 10 tips.