
How to Use EF Core Interceptors for Clean and Scalable Data Access
Article Sponsors
EF Core too slow? Insert data up to 14x faster and cut save time by 94%. Boost performance with extension methods fully integrated into EF Core — Bulk Insert, Update, Delete, and Merge.
Join 5,000+ developers who’ve trusted our library since 2014.
In most applications, database operations are not just about saving or retrieving data. There is always additional logic involved, such as auditing, logging, validation, or enforcing rules.
A common approach is overriding SaveChanges in DbContext. While this works for simple cases, it becomes harder to maintain and reuse as the application grows.
EF Core Interceptors provide a cleaner and more scalable way to handle these cross-cutting concerns without tightly coupling them to the DbContext.
What Are Interceptors in EF Core?
Interceptors in EF Core allow hooking into the internal execution pipeline and running custom logic before or after key operations.
These operations include:
- Saving changes
- Executing SQL commands
- Opening database connections
- Managing transactions
Instead of embedding logic directly inside DbContext, interceptors operate externally and interact with EF Core through well-defined extension points.
This makes them a better fit for modern applications that follow clean architecture and separation of concerns.
Why Use Interceptors?
Using EF Core Interceptors provides several benefits in real-world applications.
They help keep the DbContext clean by moving cross-cutting concerns outside of it. This improves readability and long-term maintainability.
They are also reusable, meaning the same interceptor can be applied across multiple DbContexts. From an architectural perspective, interceptors align well with Clean Architecture, where infrastructure logic should not leak into application logic.
Another key advantage is flexibility. Interceptors allow working at both high-level operations like SaveChanges and low-level operations such as SQL command execution.
Types of Interceptors in EF Core
EF Core provides a wide range of interceptors to hook into different parts of the data access pipeline.
Here are the main interceptor types available:
- ISaveChangesInterceptor – Intercepts
SaveChangesandSaveChangesAsync. Commonly used for auditing, soft delete, and validation. - IDbCommandInterceptor – Intercepts database command creation, execution, failures, and reader disposal. This is useful for SQL logging, performance monitoring, and command-level customization.
- IDbConnectionInterceptor – Intercepts connection opening, closing, creation, and connection failures. This is useful for diagnostics and connection-level monitoring.
- IDbTransactionInterceptor – Intercepts transaction creation, use of existing transactions, commits, rollbacks, savepoints, and transaction failures. Useful for transaction tracking and advanced infrastructure scenarios.
- IMaterializationInterceptor – Intercepts creating, initializing, and finalizing entity instances from query results. This can be useful when custom behavior is needed while EF Core materializes entities. Microsoft marks this as a singleton interceptor.
- IQueryExpressionInterceptor – Intercepts and can modify the LINQ expression tree before a query is compiled, as well as the resulting compiled delegates. This is also marked as a singleton interceptor.
- IIdentityResolutionInterceptor – Intercepts identity resolution conflicts when tracking entities. This is useful in advanced tracking scenarios where EF Core resolves multiple instances representing the same entity.
Commonly Used Interceptors
In day-to-day application development, the most commonly used ones are:
- ISaveChangesInterceptor for auditing, soft delete, and validation
- IDbCommandInterceptor for SQL logging and query performance analysis
- IDbConnectionInterceptor and IDbTransactionInterceptor for diagnostics and infrastructure-level control
The other interceptor types are valid and important, but they are usually needed only in more advanced scenarios.
Common Use Cases
Interceptors are useful in many real-world scenarios:
- Automatically setting audit fields (
CreatedOnUtc,UpdatedOnUtc) - Implementing soft delete
- Logging SQL queries
- Preventing accidental data modifications
- Tracking performance metrics
- Applying tenant-specific logic
Implementing an Interceptor
To keep the interceptor reusable, it is a good idea to first define a contract for the entities that need audit fields. This makes the interceptor target only the relevant entities instead of applying the logic to everything in the model.
public interface IAuditable
{
DateTime CreatedOnUtc { get; set; }
DateTime UpdatedOnUtc { get; set; }
}Once the contract is in place, the entities that need auditing can implement it. In this example, the Order entity includes CreatedOnUtc and UpdatedOnUtc fields, so the interceptor can set them automatically before data is saved
public class Order : IAuditable
{
public Guid Id { get; set; }
public string OrderNumber { get; set; } = default!;
public DateTime CreatedOnUtc { get; set; }
public DateTime UpdatedOnUtc { get; set; }
}After that, the interceptor itself can be created. A SaveChangesInterceptor is a good fit here because the auditing logic should run just before EF Core saves changes to the database.
In the example below, the interceptor checks tracked entities that implement IAuditable. If an entity is new, it sets CreatedOnUtc. If it is being updated, it refreshes UpdatedOnUtc.
internal sealed class AuditSaveChangesInterceptor: SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
DbContext? dbContext = eventData.Context;
if (dbContext is not null)
{
SetAuditableProperties(dbContext);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void SetAuditableProperties(DbContext dbContext)
{
DateTime utcNow = DateTime.UtcNow;
var entities = dbContext
.ChangeTracker
.Entries<IAuditable>();
foreach (EntityEntry<IAuditable> entityEntry in entities)
{
if (entityEntry.State is EntityState.Added)
{
entityEntry.Property(p => p.CreatedOnUtc).CurrentValue = utcNow;
}
if (entityEntry.State is EntityState.Modified)
{
entityEntry.Property(p => p.UpdatedOnUtc).CurrentValue = utcNow;
}
}
}
}Registering Interceptors in EF Core
After creating the interceptor, it needs to be registered so EF Core can execute it during the save pipeline.
In most applications, registering through dependency injection is the better option. It keeps the setup flexible and works well when the interceptor depends on other services.
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});It is also possible to register an interceptor directly inside the DbContext configuration. This works, but it is usually less flexible in larger applications.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString)
.AddInterceptors(new AuditSaveChangesInterceptor());
}Some singleton interceptors are intended to be reused across multiple DbContext instances. In those cases, creating a new interceptor object every time the context is configured is not a good idea, because it can lead to extra internal service providers being created and may hurt performance.
For a shared interceptor, reuse the same instance instead of creating a new one repeatedly.
var interceptor = new PerformanceTrackingInterceptor();
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(interceptor);
});A static instance can also be used when appropriate.
public sealed class ApplicationDbContext : DbContext
{
private static readonly PerformanceTrackingInterceptor _interceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(_interceptor);
}
}When an interceptor instance is shared, it must be thread-safe and should not keep mutable state inside it.
Interceptors vs SaveChanges Override
Both approaches allow adding logic to EF Core, but they serve different purposes.
| Feature | SaveChanges Override | Interceptors |
|---|---|---|
| Coupling | Tight | Loose |
| Reusability | Limited | High |
| Testability | Harder | Easier |
| Separation of Concerns | Limited | Strong |
| Cross-DbContext Usage | Not supported | Supported |
For simple applications, overriding SaveChanges can work. For scalable and maintainable systems, interceptors are usually the better choice.
Performance Considerations
Interceptors run frequently, so performance matters.
- Keep interceptor logic lightweight. Avoid heavy computations or external calls.
- Filter only the required entities instead of scanning the entire ChangeTracker.
- Be careful when modifying SQL in command interceptors.
- Ensure shared interceptors are reused properly to avoid unnecessary overhead.
Summary
EF Core Interceptors provide a clean and structured way to handle cross-cutting concerns in data access.
They help keep DbContext maintainable, improve reusability, and align well with modern architecture practices. Instead of scattering logic across the application, interceptors allow centralizing it in a consistent and scalable way
Takeaways
- Interceptors hook into EF Core’s execution pipeline
- They are ideal for auditing, logging, and soft delete scenarios
- They improve separation of concerns and reusability
- They are more scalable than overriding
SaveChanges - Shared interceptors must be thread-safe and reused properly
- Keep interceptor logic lightweight for better performance
This article is sponsored by ZZZ Projects.
Thousands of developers fixed EF Core performance — with one library: Entity Framework Extensions.

