Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated March 29, 2026

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.

The slow path: one SaveChanges call per row means thousands of database round trips

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 loop

This 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.

  1. EFCore.BulkExtensions for inserting and updating many rows fast.
  2. 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.

The fast path: one bulk stream carries all rows to the database in a single efficient operation

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

Read file
Bulk insert
Bulk update
Bulk delete
Done

Steps

1

Read file

Load provider file into memory once

2

Bulk insert

BulkInsert all brand-new payments

3

Bulk update

ExecuteUpdate to change statuses

4

Bulk delete

ExecuteDelete old failed rows

5

Done

Commit and log results

Each stage handles thousands of rows in a single set-based operation

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

Inserting?
Updating fields?
Deleting?
Small set?

Steps

1

Inserting?

Use BulkInsert for many new rows

2

Updating fields?

Use ExecuteUpdate, no loading

3

Deleting?

Use ExecuteDelete by condition

4

Small set?

Plain SaveChanges is fine

A simple rule of thumb for choosing the right bulk approach

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.

OperationOld wayNew wayRoughly faster
Insert new payments5 min 30 s22 s~15x
Update statuses3 min 10 s8 s~24x
Delete old rows2 min 20 s6 s~23x
Full nightly batch11 min44 s~15x

Memory use dropped just as sharply, because the change tracker was no longer holding tens of thousands of objects.

ApproachMemory for 100k rowsNotes
Add one-by-one~38 MBSlowest, biggest notebook
AddRange + SaveChanges~28 MBBetter batching
BulkInsert~18 MBSmallest, 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 SaveChanges to write audit logs or set timestamps. ExecuteUpdate and ExecuteDelete skip 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.

State of a payment row as it moves through the bulk pipeline

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

Quick recap

  • The slow code saved payments one row at a time, causing thousands of database round trips.
  • The first easy win was moving SaveChanges outside the loop.
  • BulkInsert from EFCore.BulkExtensions inserts many rows in one fast stream, skipping the change tracker.
  • ExecuteUpdate turns a LINQ query into one SQL UPDATE with no rows loaded.
  • ExecuteDelete turns a condition into one SQL DELETE, 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 SaveChanges for small ones.
  • Measure first, then optimise.

Related Posts