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:

InterceptorBase class you can inheritWhat it interceptsLifetime
ISaveChangesInterceptorSaveChangesInterceptorThe SaveChanges call itself, including failures, cancellation, and concurrency exceptionsScoped or singleton
IDbConnectionInterceptorDbConnectionInterceptorConnection lifecycle: creating, opening, and closing connectionsScoped or singleton
IDbTransactionInterceptorDbTransactionInterceptorTransaction lifecycle: creating, committing, and rolling back transactions, and savepointsScoped or singleton
IDbCommandInterceptorDbCommandInterceptorEvery SQL command sent to the databaseScoped or singleton
IQueryExpressionInterceptorNoneLINQ expression trees, before the query is compiledSingleton
IMaterializationInterceptorNoneCreation of entity instances from query resultsSingleton
IIdentityResolutionInterceptorNoneIdentity conflicts when Attach or Update tracks an entity whose key is already trackedScoped 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: the SaveChanges call itself
  • IDbConnectionInterceptor and IDbTransactionInterceptor: connection and transaction lifecycle
  • IDbCommandInterceptor: every SQL command sent to the database

The other three act on queries and tracking instead:

  • IQueryExpressionInterceptor: LINQ expression trees, before the query is compiled
  • IMaterializationInterceptor: creation of entity instances from query results
  • IIdentityResolutionInterceptor: identity conflicts when Attach or Update tracks an entity whose key is already tracked
Interceptors along a SaveChangesAsync callA SaveChangesAsync call goes through change tracking, opening a connection, starting a transaction if the save needs one, executing SQL commands, and committing that transaction. ISaveChangesInterceptor taps into the SaveChanges call, IDbConnectionInterceptor into the connection, IDbTransactionInterceptor into the transaction and the commit when EF Core starts one, and IDbCommandInterceptor into each SQL command.

await db.SaveChangesAsync()

Change tracking

Opening a connection

Starting a transaction (if needed)

Executing SQL commands

Committing (if a transaction was started)

ISaveChangesInterceptor

IDbConnectionInterceptor

IDbTransactionInterceptor

IDbCommandInterceptor

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 number SaveChanges returns 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;
}
Interceptor callbacks around one operationThe before callback can observe, modify, or suppress the operation. Observing or modifying returns the incoming result, so the operation runs. Suppressing returns SuppressWithResult, so EF Core skips the operation and passes the supplied value to the after callback. If the operation succeeds, the after callback receives its result. A canceled save calls SaveChangesCanceled, a concurrency conflict calls ThrowingConcurrencyException, and any other exception calls the failed callback.

Observe or modify: return result

Suppress: SuppressWithResult(value)

Success

Your value

Other exception

Canceled

Concurrency conflict

Before callback: SavingChanges

Operation runs

EF Core skips the operation and uses your value

After callback: SavedChanges

Failed callback: SaveChangesFailed

SaveChangesCanceled

ThrowingConcurrencyException

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 CreatedAt and CreatedBy.
  • Modified: set UpdatedAt and UpdatedBy, and mark CreatedAt and CreatedBy as not modified so the UPDATE never 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:

MethodEntitiesMeanAllocated
WithoutInterceptor1952.4 µs49.78 KB
WithAuditInterceptor1958.6 µs56.77 KB
WithoutInterceptor10011,085.7 µs1,063.50 KB
WithAuditInterceptor10010,916.0 µs1,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 caseInterceptorWhy there
Audit properties or audit recordsISaveChangesInterceptorSavingChanges sees every tracked entry before any SQL is sent
Ignoring concurrency conflicts on deletesISaveChangesInterceptorThrowingConcurrencyException runs before EF Core throws DbUpdateConcurrencyException and can suppress it
Query hintsIDbCommandInterceptorIt can change the SQL before it reaches the database, and query tags mark which commands to change
Caching a frequent queryIDbCommandInterceptorReaderExecuting can suppress the query and return cached results instead
Per-tenant connection stringsIDbConnectionInterceptorConnectionOpeningAsync 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 queriesIQueryExpressionInterceptorIt 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 entitiesIMaterializationInterceptorIt 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.