Most entities share a few audit properties: CreatedAt, UpdatedAt, CreatedBy, and UpdatedBy. They usually live in a base entity and are set whenever an entity is created or modified.
In EF Core, interceptors let you set them automatically in one place, without touching every handler or service that saves data.
As the name suggests, interceptors let you hook into EF Core’s pipeline to observe or change an operation, or to suppress it before it runs.
The main interceptor types are:
| Interceptor | Base class you can inherit | What it intercepts | Lifetime |
|---|---|---|---|
ISaveChangesInterceptor | SaveChangesInterceptor | The SaveChanges call itself, including failures, cancellation, and concurrency exceptions | Scoped or singleton |
IDbConnectionInterceptor | DbConnectionInterceptor | Connection lifecycle: creating, opening, and closing connections | Scoped or singleton |
IDbTransactionInterceptor | DbTransactionInterceptor | Transaction lifecycle: creating, committing, and rolling back transactions, and savepoints | Scoped or singleton |
IDbCommandInterceptor | DbCommandInterceptor | Every SQL command sent to the database | Scoped or singleton |
IQueryExpressionInterceptor | None | LINQ expression trees, before the query is compiled | Singleton |
IMaterializationInterceptor | None | Creation of entity instances from query results | Singleton |
IIdentityResolutionInterceptor | None | Identity conflicts when Attach or Update tracks an entity whose key is already tracked | Scoped or singleton |
How interceptors work
A single SaveChangesAsync call runs through several stages:
- Change tracking
- Opening a connection
- Starting a transaction, if the save needs one
- Executing SQL commands
- Committing that transaction
EF Core starts a transaction only when a save needs one. A single SQL statement already runs in an implicit transaction, so a one-row save like the ones later in this post usually doesn’t fire IDbTransactionInterceptor at all.
The first four interceptor types tap into these stages:
ISaveChangesInterceptor: theSaveChangescall itselfIDbConnectionInterceptorandIDbTransactionInterceptor: connection and transaction lifecycleIDbCommandInterceptor: every SQL command sent to the database
The other three act on queries and tracking instead:
IQueryExpressionInterceptor: LINQ expression trees, before the query is compiledIMaterializationInterceptor: creation of entity instances from query resultsIIdentityResolutionInterceptor: identity conflicts whenAttachorUpdatetracks an entity whose key is already tracked
The SaveChanges, connection, transaction, and command interceptors follow the same naming pattern. For SaveChanges, that’s a method that runs before the operation (SavingChanges), one that runs after it (SavedChanges), and one that runs on failure (SaveChangesFailed).
SaveChangesInterceptor also has SaveChangesCanceled, called when a save is canceled, and ThrowingConcurrencyException, which runs just before EF Core throws a DbUpdateConcurrencyException.
Most callbacks come in sync and async variants (SavingChanges and SavingChangesAsync). EF Core calls the one that matches how your code calls it, so override both to make sure your interceptor runs on either path.
A “before” callback can do three things:
- Observe: read what’s about to happen, log it, and return the incoming result unchanged
- Modify: change something before EF Core proceeds
- Suppress: skip the real operation and supply a result yourself
Returning InterceptionResult<T>.SuppressWithResult(value) makes EF Core skip the operation and use your value instead. Some operations have no result value, and for those you return InterceptionResult.Suppress(). Returning the incoming result unchanged keeps whatever was decided so far, so unless another interceptor suppressed the operation, EF Core continues.
// Observe or modify: let EF Core continue
return result;
// Suppress: skip the save and report 0 affected rows
return InterceptionResult<int>.SuppressWithResult(0);
A suppressed save still calls SavedChanges, with your value (0 here) as the result. EF Core doesn’t accept the changes, so tracked entities stay Added or Modified.
“After” callbacks run once the operation has finished, and they receive its result.
They can:
- Observe: log the outcome, such as the number of rows affected, how long a command took, or the saved entities
- Change the result: many “after” methods return the result they received, so returning a different value changes the result EF Core uses. For
SavedChanges, that’s the numberSaveChangesreturns to your code
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"Saved {result} row(s)");
return ValueTask.FromResult(result);
}
They can’t skip or reverse the operation. For SaveChanges, any transaction EF Core started has already committed by then.
“Failed” callbacks run when the operation throws. Cancellation and concurrency conflicts are handled separately: a canceled save calls SaveChangesCanceled, and a DbUpdateConcurrencyException calls ThrowingConcurrencyException. Neither calls SaveChangesFailed.
They are for observing: log the exception, record a metric, or clean up state.
public override Task SaveChangesFailedAsync(
DbContextErrorEventData eventData,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"Save failed: {eventData.Exception.Message}");
return Task.CompletedTask;
}
SaveChangesInterceptor in action
The interceptor I’ve used most over the years is a SaveChangesInterceptor for audit properties.
I keep them in a base entity:
public abstract class AuditableEntity
{
public DateTimeOffset CreatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTimeOffset? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
Every other entity inherits it:
public sealed class Product : AuditableEntity
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
}
The DbContext doesn’t know who the current user is, so the interceptor gets it from a small abstraction. In a web app, the implementation reads the user from HttpContext.
public interface ICurrentUser
{
string UserId { get; }
}
Next is the interceptor:
public sealed class AuditInterceptor(ICurrentUser currentUser) : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
ApplyAuditValues(eventData.Context);
return result;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ApplyAuditValues(eventData.Context);
return ValueTask.FromResult(result);
}
private void ApplyAuditValues(DbContext? context)
{
if (context is null)
{
return;
}
var now = DateTimeOffset.UtcNow;
var userId = currentUser.UserId;
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = userId;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
entry.Property(e => e.CreatedAt).IsModified = false;
entry.Property(e => e.CreatedBy).IsModified = false;
break;
}
}
}
}
SaveChangesInterceptor implements ISaveChangesInterceptor with no-op virtual methods that return the incoming result, so I override only what I need. I use SavingChanges because it’s a “before” callback: it runs before any SQL is sent, while the entities can still be changed. I override both the sync and async versions, because EF Core calls only the one that matches how you save.
ChangeTracker.Entries<AuditableEntity>() first runs DetectChanges(), which compares each tracked entity with the snapshot taken when it was first tracked. Any difference marks the entity as Modified. Entities passed to Add are already Added, and DetectChanges() marks new entities it finds through navigations as Added too.
That call matters because SavingChanges runs before SaveChanges does its own DetectChanges(). Without it, an entity edited by plain property assignment would still be Unchanged here and would never get UpdatedAt or UpdatedBy.
Based on the state:
- Added: set
CreatedAtandCreatedBy. - Modified: set
UpdatedAtandUpdatedBy, and markCreatedAtandCreatedByas not modified so theUPDATEnever overwrites them.
The method hands result back untouched, so the save goes ahead as usual. That’s the modify case from earlier: the interceptor changes the entities, not the outcome of the save.
Registration and lifetime
builder.Services.AddScoped<CurrentUser>();
builder.Services.AddScoped<ICurrentUser>(sp => sp.GetRequiredService<CurrentUser>());
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
options
.UseNpgsql(builder.Configuration.GetConnectionString("Default"))
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
AuditInterceptor needs ICurrentUser, and the user changes with every request. So I register it as scoped and resolve it from the same scope as the DbContext. Each request gets its own interceptor, and every save is stamped with the user who made it.
If it only set CreatedAt and UpdatedAt, it would hold nothing per request, and one singleton instance could serve every DbContext.
Watch out for DbContext pooling. With
AddDbContextPool, options are built once, from the root service provider, and reused by every pooled context. The scoped interceptor is resolved only once, so expect either a scope validation error or every save stamped with the same user.
Calling SaveChangesAsync and observing the result
var product = new Product { Name = "Mechanical keyboard", Price = 129.99m };
db.Products.Add(product);
var affected = await db.SaveChangesAsync();
SaveChangesAsync affected 1 row(s)
--- After insert ---
Name: Mechanical keyboard
Price: 129.99
CreatedAt: 2026-10-27T19:50:46.2656266+00:00 by alice
UpdatedAt: - by -
var product = await db.Products.SingleAsync(p => p.Id == productId);
product.Price = 99.99m;
var affected = await db.SaveChangesAsync();
SaveChangesAsync affected 1 row(s)
--- After update ---
Name: Mechanical keyboard
Price: 99.99
CreatedAt: 2026-10-27T19:50:46.2656260+00:00 by alice
UpdatedAt: 2026-10-27T19:50:47.5228979+00:00 by bob
UPDATE "Products" SET "Price" = @p0, "UpdatedAt" = @p1, "UpdatedBy" = @p2
WHERE "Id" = @p3;
The UPDATE contains Price because it changed, UpdatedAt and UpdatedBy because the interceptor set them, and no CreatedAt or CreatedBy, because the interceptor marked them as not modified. SaveChangesAsync still returns the number of affected rows.
The same happens with the sync SaveChanges():
var product = await db.Products.SingleAsync(p => p.Id == productId);
product.Name = "Mechanical keyboard (TKL)";
var affected = db.SaveChanges();
SaveChanges affected 1 row(s)
--- After sync update ---
Name: Mechanical keyboard (TKL)
Price: 99.99
CreatedAt: 2026-10-27T19:50:46.2656260+00:00 by alice
UpdatedAt: 2026-10-27T19:50:47.5315125+00:00 by carol
Measuring performance
I benchmarked inserting 1 and 100 products in one SaveChangesAsync call, with and without the interceptor:
| Method | Entities | Mean | Allocated |
|---|---|---|---|
| WithoutInterceptor | 1 | 952.4 µs | 49.78 KB |
| WithAuditInterceptor | 1 | 958.6 µs | 56.77 KB |
| WithoutInterceptor | 100 | 11,085.7 µs | 1,063.50 KB |
| WithAuditInterceptor | 100 | 10,916.0 µs | 1,126.18 KB |
BenchmarkDotNet v0.15.8, .NET 10.0.3, Intel Core i7-13620H, PostgreSQL 18 in Docker.
The interceptor has no measurable effect on speed. A full one-entity save, database round trip included, averages about 0.95 ms, while looping over tracked entries takes microseconds. The timing differences are under 2% and go in both directions, which is within normal run-to-run variation. The real cost is memory: about 7 KB (14%) more for a one-entity save, and about 6% more with 100 entities.
That overhead doesn’t matter for a typical save, as long as the interceptor stays cheap. Avoid database calls, HTTP requests, or other slow work inside SavingChanges, because that cost is added to every save in your application.
Where to use interceptors
| Use case | Interceptor | Why there |
|---|---|---|
| Audit properties or audit records | ISaveChangesInterceptor | SavingChanges sees every tracked entry before any SQL is sent |
| Ignoring concurrency conflicts on deletes | ISaveChangesInterceptor | ThrowingConcurrencyException runs before EF Core throws DbUpdateConcurrencyException and can suppress it |
| Query hints | IDbCommandInterceptor | It can change the SQL before it reaches the database, and query tags mark which commands to change |
| Caching a frequent query | IDbCommandInterceptor | ReaderExecuting can suppress the query and return cached results instead |
| Per-tenant connection strings | IDbConnectionInterceptor | ConnectionOpeningAsync runs before the connection opens and can do async work to set it up. For Azure AD, SqlClient supports authentication through the connection string, so you usually don’t need an interceptor for it |
| Stable ordering for paged queries | IQueryExpressionInterceptor | It rewrites the LINQ expression tree before compilation, so one interceptor can change every query. It runs once per query shape, and for a few queries, adding ThenBy directly is simpler |
| Setting unmapped properties or injecting services into entities | IMaterializationInterceptor | It runs when EF Core creates an entity from query results |
Final words
Interceptors give you one place for cross-cutting concerns such as auditing, logging, and connection handling. Used where they fit, they keep that code out of every handler and service that touches the database.