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.
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.
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 type | When it runs | Good for |
|---|---|---|
ISaveChangesInterceptor | Around SaveChanges / SaveChangesAsync | Auditing, timestamps, soft delete, validation |
IDbCommandInterceptor | Around each SQL command | Logging SQL, timing slow queries, tweaking commands |
IDbConnectionInterceptor | Around opening a connection | Setting connection state, tenant context |
IDbTransactionInterceptor | Around transactions | Tracking commits and rollbacks |
IMaterializationInterceptor | When rows turn into objects | Adjusting 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
Steps
Goal
What do you want to change?
SaveChanges?
Audit or soft delete -> ISaveChangesInterceptor
Command?
Log or time SQL -> IDbCommandInterceptor
Done
Write the small class
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.
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
Steps
Remove
Code calls Remove()
Deleted state
EF marks entry Deleted
Interceptor
Flip to Modified, set IsDeleted
Update row
DB runs UPDATE not DELETE
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.
| Method | Runs | Typical use |
|---|---|---|
ReaderExecuting | Before a SELECT | Inspect or change the command |
ReaderExecuted | After a SELECT | Log timing and results |
NonQueryExecuting | Before insert/update/delete | Audit write commands |
CommandFailed | When a command errors | Capture 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.
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.
| Concern | Without interceptor | With interceptor |
|---|---|---|
Set CreatedAtUtc | Repeated in every service | Written once, runs everywhere |
| Soft delete | Easy to forget the flag | Always applied at save |
| Logging slow SQL | Manual stopwatch code | One central interceptor |
| Risk of mistakes | High, copy-paste errors | Low, 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(aroundSaveChanges) andIDbCommandInterceptor(around SQL commands). Base classes likeSaveChangesInterceptormake them easy to write. - Use
SavingChangesAsyncto set audit timestamps by looping over the change tracker and checking theAddedorModifiedstate. - For soft delete, flip a
Deletedentry toModified, setIsDeleted = true, and add a query filter so deleted rows stay hidden. - A command interceptor like
ReaderExecutedAsynccan log slow queries usingeventData.Duration. - Register interceptors with
optionsBuilder.AddInterceptors(...), either inOnConfiguringor through dependency injection inProgram.cs. Register each instance once. - Keep interceptor logic light, mind async, watch the order, and test the behaviour.
References and further reading
- Interceptors - EF Core (Microsoft Learn) - the official documentation, with every interceptor type and method.
- EntityFramework.Docs on GitHub - the source for the docs above, useful for the latest changes.
- How To Use EF Core Interceptors (Milan Jovanovic) - a clear community walkthrough with auditing examples.
- Soft Deletes in EF Core 10 (codewithmukesh) - interceptors, named filters, and cascade delete in EF Core 10.
- Using EF Core Interceptors in .NET (Code Maze) - more worked examples and patterns.
Related Posts
5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
EF Core Raw SQL Queries: FromSql, SqlQuery, and ExecuteSql Explained
A friendly, beginner guide to raw SQL in EF Core: FromSql for entities, SqlQuery for scalars, ExecuteSql for writes, and how to stay safe from SQL injection.
How to Use Global Query Filters in EF Core (Beginner Guide)
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in EF Core 10.
Global Query Filters in EF Core: Soft Delete and Multi-Tenancy Made Easy
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in .NET 10.
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.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.