Skip to main content
SEMastery
Data Accessbeginner

How to Use EF Core Interceptors: A Beginner-Friendly Guide

Learn EF Core interceptors step by step. Add auditing, soft delete, logging, and timing to your DbContext with clean, reusable code and zero clutter.

11 min readUpdated March 21, 2026

Imagine you run a small shop. Every time someone buys something, you want to write the date and the customer name in a notebook. You could ask each cashier to remember to do this. But cashiers forget. A better idea is to hire one helper who stands at the door. Every receipt passes through this helper first. The helper stamps the date and name, then lets the receipt go through. Now it always happens, and your cashiers do not have to think about it.

An EF Core interceptor is that helper at the door. It sits between your code and the database. EF Core calls it at the right moment, every single time, so you never forget. This guide will show you what interceptors are, the main kinds, and how to build a few useful ones with simple, copy-paste-ready code.

What is an interceptor, really?

When you write context.SaveChanges() or run a query, EF Core does a lot of work behind the scenes. It builds SQL, opens a connection, sends the command, and reads the result. Normally you do not see any of this. It just happens.

An interceptor lets you step into that hidden process. You can watch what is happening, change it, or even stop it. EF Core gives you hooks at many points. You pick the point you care about and write a small class.

The big win is clean code. Without interceptors, you copy the same "set the timestamp" line into every save. With an interceptor, you write it once. Your business code stays focused on business things.

Where an interceptor sits in the EF Core pipeline

The main kinds of interceptors

EF Core has several interceptor types, but two cover almost everything you will need as a beginner. Here is a quick map.

Interceptor typeWhen it runsGood for
ISaveChangesInterceptorAround SaveChanges / SaveChangesAsyncAuditing, timestamps, soft delete, validation
IDbCommandInterceptorAround each SQL commandLogging SQL, timing slow queries, tweaking commands
IDbConnectionInterceptorAround opening a connectionSetting connection state, tenant context
IDbTransactionInterceptorAround transactionsTracking commits and rollbacks
IMaterializationInterceptorWhen rows turn into objectsAdjusting loaded entities

Most apps lean heavily on the first two rows. We will spend most of our time there.

To make life easier, EF Core ships base classes like SaveChangesInterceptor and DbCommandInterceptor. These already have empty methods for every hook. You only override the one or two methods you care about, and ignore the rest.

Choosing an interceptor type

Goal
SaveChanges?
Command?
Done

Steps

1

Goal

What do you want to change?

2

SaveChanges?

Audit or soft delete -> ISaveChangesInterceptor

3

Command?

Log or time SQL -> IDbCommandInterceptor

4

Done

Write the small class

Pick the hook that matches your goal

Setting the scene: a tiny model

Let us build a small example. We have a Product entity and a base class that holds audit fields. Many entities will share these fields, so we put them in one place.

public abstract class AuditableEntity
{
    public DateTime CreatedAtUtc { get; set; }
    public DateTime? UpdatedAtUtc { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAtUtc { get; set; }
}
 
public class Product : AuditableEntity
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

The fields like CreatedAtUtc and IsDeleted are the data our interceptors will fill in automatically. We never want to set them by hand in business code.

Example 1: An auditing interceptor

Our first helper sets timestamps. When a new product is added, it should stamp CreatedAtUtc. When a product changes, it should stamp UpdatedAtUtc. We do this by reading the change tracker, which is the list of entities EF Core is about to save.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
 
public sealed class AuditingInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context is null)
        {
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }
 
        var now = DateTime.UtcNow;
 
        foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedAtUtc = now;
            }
            else if (entry.State == EntityState.Modified)
            {
                entry.Entity.UpdatedAtUtc = now;
            }
        }
 
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

Read it slowly. The method SavingChangesAsync runs just before the save reaches the database. We grab the context, loop over every tracked AuditableEntity, and check its state. Added means it is new. Modified means it changed. We set the right field and let the save continue by calling base.

Notice we override the async method. EF Core has both a sync SavingChanges and an async SavingChangesAsync. If your app calls SaveChangesAsync, override the async one. To be safe for both, you can override both and share a private helper.

The auditing interceptor at save time

Example 2: A soft delete interceptor

A soft delete means we do not really remove a row. We just mark it as deleted with a flag. The row stays in the table, so we can recover it or keep history. This is very common in real apps.

The trick is to catch entities that EF Core wants to delete and quietly turn them into updates instead. EF Core 10 also has named query filters that make this cleaner, but the interceptor does the core work.

public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context is null)
        {
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }
 
        foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
        {
            if (entry.State != EntityState.Deleted)
            {
                continue;
            }
 
            // Turn the delete into an update.
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAtUtc = DateTime.UtcNow;
        }
 
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

When someone calls context.Products.Remove(product) and then saves, EF Core marks the entry as Deleted. Our interceptor sees that, flips the state to Modified, and sets IsDeleted = true. The database gets an UPDATE, not a DELETE. The row lives on.

To hide soft-deleted rows from normal queries, add a query filter in OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .HasQueryFilter(p => !p.IsDeleted);
}

Now context.Products.ToList() only returns rows where IsDeleted is false. The filter and the interceptor work as a team. One hides deleted rows on read, the other marks them on write.

Soft delete flow

Remove
Deleted state
Interceptor
Update row

Steps

1

Remove

Code calls Remove()

2

Deleted state

EF marks entry Deleted

3

Interceptor

Flip to Modified, set IsDeleted

4

Update row

DB runs UPDATE not DELETE

A delete becomes a flagged update

Example 3: A command interceptor for slow SQL

The save interceptors work at a high level. Sometimes you want to look at the actual SQL EF Core sends. The IDbCommandInterceptor (and its base class DbCommandInterceptor) lets you do that. A common use is to log queries that take too long.

using System.Data.Common;
using System.Diagnostics;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
 
public sealed class SlowQueryInterceptor : DbCommandInterceptor
{
    private readonly ILogger<SlowQueryInterceptor> _logger;
    private const long ThresholdMs = 500;
 
    public SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger)
    {
        _logger = logger;
    }
 
    public override async ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Duration.TotalMilliseconds > ThresholdMs)
        {
            _logger.LogWarning(
                "Slow query ({Elapsed} ms): {Sql}",
                eventData.Duration.TotalMilliseconds,
                command.CommandText);
        }
 
        return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
    }
}

ReaderExecutedAsync runs after the query finishes, so EF Core hands us eventData.Duration. If a query is slower than half a second, we write a warning with the SQL text. This is a gentle way to spot performance problems in production without touching your query code.

The table below shows handy command hooks you can override.

MethodRunsTypical use
ReaderExecutingBefore a SELECTInspect or change the command
ReaderExecutedAfter a SELECTLog timing and results
NonQueryExecutingBefore insert/update/deleteAudit write commands
CommandFailedWhen a command errorsCapture failures with the SQL

Registering your interceptors

A helper does nothing until you hire it. You register interceptors when you configure the DbContext. There are two common ways.

The first is inside OnConfiguring, which is fine for simple apps:

public class ShopDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();
 
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.AddInterceptors(
            new AuditingInterceptor(),
            new SoftDeleteInterceptor());
    }
}

The second, and usually better, way is in Program.cs with dependency injection. This lets interceptors receive services like a logger:

builder.Services.AddSingleton<AuditingInterceptor>();
builder.Services.AddSingleton<SoftDeleteInterceptor>();
builder.Services.AddSingleton<SlowQueryInterceptor>();
 
builder.Services.AddDbContext<ShopDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString)
        .AddInterceptors(
            sp.GetRequiredService<AuditingInterceptor>(),
            sp.GetRequiredService<SoftDeleteInterceptor>(),
            sp.GetRequiredService<SlowQueryInterceptor>());
});

Each interceptor instance should be registered only once, even if it implements more than one interface. Register the same object once and EF Core will call every hook it supports.

How registration connects everything

Things to watch out for

Interceptors are powerful, so a little care keeps you safe.

Keep the logic light. The save interceptor loops over tracked entities. If you also run extra database calls inside that loop, every save gets slower. Do the minimum and get out.

Mind sync versus async. If your app uses SaveChangesAsync, the sync SavingChanges method will not run. Override the async version, or override both and share one helper method so behaviour never drifts apart.

Order can matter. When two save interceptors touch the same entity, EF Core calls them in the order you registered them. If soft delete should run before auditing, register it first. Think about the order on purpose.

Do not swallow errors. If something goes wrong inside an interceptor, let it bubble up. Hiding an exception here can lead to data that looks saved but is not.

Test the behaviour. Because interceptors run on every save, a small bug spreads everywhere. Write a quick test that adds, edits, and deletes an entity, then checks the audit fields. A few minutes of testing saves hours of confusion later.

A quick comparison: with and without interceptors

It helps to see the difference side by side.

ConcernWithout interceptorWith interceptor
Set CreatedAtUtcRepeated in every serviceWritten once, runs everywhere
Soft deleteEasy to forget the flagAlways applied at save
Logging slow SQLManual stopwatch codeOne central interceptor
Risk of mistakesHigh, copy-paste errorsLow, single source of truth

The interceptor column wins on almost every row. That is why teams reach for them so often once they learn the pattern.

Quick recap

  • An EF Core interceptor is a helper that sits between your code and the database and runs automatically at key moments.
  • The two most useful kinds are ISaveChangesInterceptor (around SaveChanges) and IDbCommandInterceptor (around SQL commands). Base classes like SaveChangesInterceptor make them easy to write.
  • Use SavingChangesAsync to set audit timestamps by looping over the change tracker and checking the Added or Modified state.
  • For soft delete, flip a Deleted entry to Modified, set IsDeleted = true, and add a query filter so deleted rows stay hidden.
  • A command interceptor like ReaderExecutedAsync can log slow queries using eventData.Duration.
  • Register interceptors with optionsBuilder.AddInterceptors(...), either in OnConfiguring or through dependency injection in Program.cs. Register each instance once.
  • Keep interceptor logic light, mind async, watch the order, and test the behaviour.

References and further reading

Related Posts