Home C# / .NET EF Core Interceptors and Events: Deep Dive for Production Apps
Advanced 3 min · July 13, 2026

EF Core Interceptors and Events: Deep Dive for Production Apps

Master EF Core interceptors and events to audit changes, handle concurrency, and optimize queries.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 13, 2026
last updated
2,043
articles · all by Naren
Before you start⏱ 20-25 min read
  • Basic knowledge of C# and .NET
  • Familiarity with Entity Framework Core (DbContext, migrations)
  • Understanding of dependency injection in ASP.NET Core
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Interceptors allow you to hook into EF Core operations (SaveChanges, query execution) to add cross-cutting concerns like auditing, logging, or caching.
  • Events like ChangeTracker.Tracked and StateChanged let you react to entity state changes in real-time.
  • Use interceptors for global behavior; use events for per-context reactions.
  • Always dispose of resources properly and avoid blocking async operations inside interceptors.
  • Production debugging: check interceptor registration order and exception handling.
✦ Definition~90s read
What is EF Core Interceptors and Events?

EF Core interceptors and events are hooks that allow you to inject custom logic into database operations (like SaveChanges or query execution) or react to entity state changes, enabling cross-cutting concerns without modifying business code.

Think of EF Core interceptors like security cameras in a store.
Plain-English First

Think of EF Core interceptors like security cameras in a store. Every time a customer (query) enters or a purchase (SaveChanges) happens, the camera records it. You can review footage later for audits. Events are like doorbells that ring when someone enters or leaves, letting you react immediately. Both help you monitor and control what happens in your database interactions.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

In production applications, you often need to add cross-cutting concerns like auditing, logging, caching, or soft-delete logic to your database operations. Doing this manually in every repository or service leads to code duplication and maintenance nightmares. EF Core provides two powerful mechanisms: interceptors and events. Interceptors allow you to intercept low-level operations (like command execution, SaveChanges, or connection opening) to add custom behavior. Events like ChangeTracker.Tracked and StateChanged let you react to entity state changes as they happen. This tutorial dives deep into both, with production-ready C# examples, a real-world incident story, and debugging strategies. You'll learn how to implement an audit trail interceptor, handle concurrency conflicts, and avoid common pitfalls. By the end, you'll be able to add robust, maintainable cross-cutting logic to any EF Core application.

Understanding EF Core Interceptors

EF Core interceptors are classes that implement one or more interfaces from the Microsoft.EntityFrameworkCore.Diagnostics namespace. They allow you to intercept database operations such as command execution, SaveChanges, connection opening, and more. Interceptors are registered per DbContext instance via the options builder. There are three main categories: SaveChanges interceptors (ISaveChangesInterceptor), command interceptors (ICommandInterceptor), and connection interceptors (IConnectionInterceptor). Each provides before/after events (synchronous and asynchronous). For example, ISaveChangesInterceptor has SavingChangesAsync and SavedChangesAsync methods. You can use them to log changes, modify entities, or abort the operation. Interceptors are powerful but must be used carefully to avoid side effects like infinite recursion or performance bottlenecks.

AuditInterceptor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

public class AuditInterceptor : ISaveChangesInterceptor
{
    private readonly IAuditLogger _auditLogger;
    private static readonly AsyncLocal<bool> _isSaving = new AsyncLocal<bool>();

    public AuditInterceptor(IAuditLogger auditLogger)
    {
        _auditLogger = auditLogger;
    }

    public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (_isSaving.Value)
            return result; // prevent re-entry

        _isSaving.Value = true;
        try
        {
            var context = eventData.Context;
            var entries = context.ChangeTracker.Entries()
                .Where(e => e.State == EntityState.Added ||
                            e.State == EntityState.Modified ||
                            e.State == EntityState.Deleted);

            foreach (var entry in entries)
            {
                var auditEntry = new AuditEntry
                {
                    EntityName = entry.Entity.GetType().Name,
                    Action = entry.State.ToString(),
                    Timestamp = DateTime.UtcNow,
                    Changes = entry.Properties
                        .Where(p => p.IsModified || entry.State == EntityState.Added)
                        .ToDictionary(p => p.Metadata.Name, p => p.CurrentValue?.ToString())
                };
                await _auditLogger.LogAsync(auditEntry, cancellationToken);
            }
        }
        finally
        {
            _isSaving.Value = false;
        }
        return result;
    }

    // Synchronous version for completeness
    public InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        return SavingChangesAsync(eventData, result).GetAwaiter().GetResult();
    }
}
⚠ Avoid Recursive SaveChanges
📊 Production Insight
In high-load systems, consider using a background queue to write audit logs asynchronously to avoid slowing down the main SaveChanges operation.
🎯 Key Takeaway
Interceptors provide hooks for cross-cutting concerns. Always guard against re-entrancy and avoid synchronous blocking in async methods.

Registering Interceptors with Dependency Injection

Interceptors are registered via the DbContext options builder. In ASP.NET Core, you typically configure this in the ConfigureServices method (or Program.cs). You can add interceptors using AddInterceptors() on the options builder. Interceptors can be registered as transient, scoped, or singleton. However, because interceptors are often stateless or have singleton dependencies, it's recommended to register them as singletons to avoid creating multiple instances per DbContext. If your interceptor has scoped dependencies (e.g., a scoped audit logger), you need to use a scoped interceptor or resolve the dependency from the DbContext's service provider. The example below shows registration with a singleton interceptor that uses a scoped service via the context's service provider.

Program.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Register the interceptor as singleton
builder.Services.AddSingleton<AuditInterceptor>();

// Register DbContext with interceptor
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
    var interceptor = serviceProvider.GetRequiredService<AuditInterceptor>();
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
           .AddInterceptors(interceptor);
});

// Register scoped audit logger
builder.Services.AddScoped<IAuditLogger, AuditLogger>();

var app = builder.Build();
// ... rest of app
💡Scoped Dependencies in Singleton Interceptors
📊 Production Insight
In microservices, consider registering interceptors per DbContext type to avoid unintended side effects across different contexts.
🎯 Key Takeaway
Register interceptors as singletons when possible. Use the DbContext's service provider to resolve scoped dependencies.

Using ChangeTracker Events

EF Core's ChangeTracker exposes events that fire when entities are tracked or their state changes. The two main events are: Tracked (fires when an entity is first tracked) and StateChanged (fires when an entity's state changes after being tracked). These events are useful for scenarios like caching, validation, or updating denormalized data. Unlike interceptors, events are per-context and are not global. You subscribe to them on the ChangeTracker instance of your DbContext. The example below shows how to use StateChanged to automatically update a LastModified property on entities.

AppDbContext.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;

public class AppDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
        ChangeTracker.StateChanged += OnStateChanged;
        ChangeTracker.Tracked += OnTracked;
    }

    private void OnStateChanged(object sender, EntityStateChangedEventArgs e)
    {
        if (e.NewState == EntityState.Modified || e.NewState == EntityState.Added)
        {
            if (e.Entry.Entity is IAuditable auditable)
            {
                auditable.LastModified = DateTime.UtcNow;
            }
        }
    }

    private void OnTracked(object sender, EntityTrackedEventArgs e)
    {
        // Example: log tracking
        Console.WriteLine($"Entity {e.Entry.Entity.GetType().Name} tracked with state {e.Entry.State}");
    }
}

public interface IAuditable
{
    DateTime? LastModified { get; set; }
}

public class Product : IAuditable
{
    public int Id { get; set; }
    public string Name { get; set; }
    public DateTime? LastModified { get; set; }
}
🔥Events vs Interceptors
📊 Production Insight
Be cautious with long-running operations in event handlers; they can block SaveChanges. Offload heavy work to background tasks.
🎯 Key Takeaway
ChangeTracker events allow you to react to entity state changes without modifying entity classes. Use them for automatic property updates or logging.

Command Interceptors for Query Caching

Command interceptors allow you to intercept SQL command execution. You can use them to cache query results, log slow queries, or modify commands. The ICommandInterceptor interface provides methods like CommandExecuting and CommandExecuted. For caching, you can check if a query result is already cached before executing the command. The example below shows a simple query cache interceptor that caches results based on the command text. Note: This is a simplified example; production caching should consider parameters, normalization, and expiration.

QueryCacheInterceptor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Collections.Concurrent;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;

public class QueryCacheInterceptor : ICommandInterceptor
{
    private static readonly ConcurrentDictionary<string, object> _cache = new();

    public InterceptionResult<DbDataReader> CommandExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        var key = command.CommandText;
        if (_cache.TryGetValue(key, out var cached))
        {
            // Return cached result as a DbDataReader? Not straightforward.
            // This example just logs; real implementation would need to return a cached reader.
            Console.WriteLine($"Cache hit for: {key}");
        }
        return result;
    }

    public async ValueTask<InterceptionResult<DbDataReader>> CommandExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        // Similar logic
        return result;
    }

    // Other methods omitted for brevity
}
⚠ Caching DbDataReader is Complex
📊 Production Insight
For query caching, prefer a dedicated caching layer (e.g., Redis) with cache invalidation based on entity changes, rather than intercepting commands.
🎯 Key Takeaway
Command interceptors can intercept SQL execution. Use them for logging, monitoring, or modifying commands, but avoid complex caching implementations.

Soft Delete with Interceptors

Soft delete is a common pattern where records are marked as deleted instead of physically removed. With an interceptor, you can automatically convert delete operations to updates on a 'IsDeleted' column. This ensures that all deletes in your application go through the soft delete logic without requiring changes to business code. The interceptor overrides the SavingChanges method to change the state of entities that implement an ISoftDelete interface from Deleted to Modified, and sets the IsDeleted flag.

SoftDeleteInterceptor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

public interface ISoftDelete
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAt { get; set; }
}

public class SoftDeleteInterceptor : ISaveChangesInterceptor
{
    public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context == null) return result;

        var deletedEntries = context.ChangeTracker.Entries<ISoftDelete>()
            .Where(e => e.State == EntityState.Deleted);

        foreach (var entry in deletedEntries)
        {
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAt = DateTime.UtcNow;
        }

        return result;
    }

    // Synchronous version
    public InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        return SavingChangesAsync(eventData, result).GetAwaiter().GetResult();
    }
}
💡Global Query Filters for Soft Delete
📊 Production Insight
Be aware that soft delete can affect unique constraints. Consider using a composite unique index that includes IsDeleted to allow multiple 'deleted' records with the same unique key.
🎯 Key Takeaway
Interceptors can transparently implement soft delete by converting deletes to updates. Always pair with query filters to hide deleted records.

Concurrency Conflict Handling with Interceptors

EF Core supports optimistic concurrency using a concurrency token (e.g., a row version column). When a DbUpdateConcurrencyException occurs, you can use an interceptor to handle it gracefully, e.g., by retrying or logging. The IConcurrencyInterceptor interface (available in EF Core 6+) provides methods for handling concurrency conflicts. Alternatively, you can catch the exception in the interceptor's SaveChangesFailed method. The example below shows a simple retry interceptor that retries the operation up to three times with exponential backoff.

ConcurrencyRetryInterceptor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

public class ConcurrencyRetryInterceptor : ISaveChangesInterceptor
{
    private const int MaxRetries = 3;

    public async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        // Not used for retry; we handle in exception
        return result;
    }

    public async Task SaveChangesFailedAsync(
        DbContextErrorEventData eventData,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Exception is DbUpdateConcurrencyException concurrencyEx)
        {
            var context = eventData.Context;
            int retryCount = 0;
            while (retryCount < MaxRetries)
            {
                retryCount++;
                // Reload the entity and retry
                foreach (var entry in concurrencyEx.Entries)
                {
                    await entry.ReloadAsync(cancellationToken);
                }
                try
                {
                    await context.SaveChangesAsync(cancellationToken);
                    return; // success
                }
                catch (DbUpdateConcurrencyException)
                {
                    // exponential backoff
                    await Task.Delay(100 * retryCount, cancellationToken);
                }
            }
            // If still failing, throw
            throw;
        }
    }
}
🔥Concurrency Handling Best Practices
📊 Production Insight
In high-contention scenarios, consider using a queue or saga pattern instead of retrying in the interceptor to avoid blocking the HTTP request.
🎯 Key Takeaway
Interceptors can handle concurrency exceptions by retrying with fresh data. Ensure retry logic is bounded and includes backoff.
● Production incidentPOST-MORTEMseverity: high

The Phantom Audit Logs: How an Interceptor Caused a Production Outage

Symptom
Database CPU spiked to 100%, queries timed out, and audit log table grew by millions of rows in minutes.
Assumption
The developer assumed the audit interceptor would only log changes once per SaveChanges.
Root cause
The interceptor was registered as a transient service and injected into multiple scopes, causing it to be called recursively. Each SaveChanges inside the interceptor triggered another SaveChanges, leading to infinite recursion.
Fix
Changed interceptor registration to singleton and added a flag to prevent re-entry. Also used a separate DbContext with a different connection string for audit logs.
Key lesson
  • Always register interceptors as singletons unless they have scoped dependencies.
  • Avoid calling SaveChanges inside SaveChanges interceptors; use a separate context or queue.
  • Use a re-entrancy guard (e.g., AsyncLocal flag) to prevent infinite loops.
  • Test interceptors with integration tests that simulate real-world load.
  • Monitor interceptor performance in production with telemetry.
Production debug guideSymptom to Action4 entries
Symptom · 01
SaveChanges is extremely slow or hangs
Fix
Check if an interceptor is calling SaveChanges recursively. Add logging to interceptor methods and look for repeated calls. Use AsyncLocal flag to detect re-entry.
Symptom · 02
Audit logs are missing or incomplete
Fix
Verify interceptor registration order. Ensure the interceptor is not skipped due to exception. Check if the interceptor is registered for the correct DbContext.
Symptom · 03
Memory leak or high memory usage
Fix
Interceptors that capture state in closures or static collections can cause leaks. Ensure no references to large objects are held. Use dependency injection with caution.
Symptom · 04
Queries return stale data
Fix
Check if a query interceptor is modifying the query in unexpected ways. Log the generated SQL and compare with expected output.
★ Quick Debug Cheat Sheet for EF Core InterceptorsCommon symptoms and immediate actions to diagnose interceptor issues.
Infinite loop / stack overflow
Immediate action
Add re-entrancy guard (AsyncLocal<bool>).
Commands
Check if _isSaving is true before proceeding.
Log entry and exit of interceptor methods.
Fix now
Set a flag to prevent recursive calls.
Interceptor not firing+
Immediate action
Verify registration in DbContext options.
Commands
Check AddInterceptors() call.
Ensure interceptor is not filtered by event type.
Fix now
Register interceptor explicitly.
Performance degradation+
Immediate action
Profile interceptor execution time.
Commands
Add Stopwatch logging.
Check for synchronous over async (e.g., .Result).
Fix now
Use async methods and avoid blocking.
FeatureInterceptorsEvents
ScopeGlobal (per DbContext options)Per-context instance
HooksLow-level (command, connection, SaveChanges)Entity state changes (Tracked, StateChanged)
Use caseAuditing, caching, soft delete, concurrencyAutomatic property updates, logging
RegistrationVia AddInterceptors() on options builderSubscribe on ChangeTracker instance
Re-entrancy riskHigh (especially SaveChanges)Low (events don't trigger SaveChanges)
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
AuditInterceptor.csusing Microsoft.EntityFrameworkCore.Diagnostics;Understanding EF Core Interceptors
Program.csusing Microsoft.EntityFrameworkCore;Registering Interceptors with Dependency Injection
AppDbContext.csusing Microsoft.EntityFrameworkCore;Using ChangeTracker Events
QueryCacheInterceptor.csusing Microsoft.EntityFrameworkCore.Diagnostics;Command Interceptors for Query Caching
SoftDeleteInterceptor.csusing Microsoft.EntityFrameworkCore;Soft Delete with Interceptors
ConcurrencyRetryInterceptor.csusing Microsoft.EntityFrameworkCore.Diagnostics;Concurrency Conflict Handling with Interceptors

Key takeaways

1
EF Core interceptors provide hooks for database operations; use them for cross-cutting concerns like auditing, soft delete, and concurrency handling.
2
Always guard against re-entrancy in SaveChanges interceptors to avoid infinite loops.
3
Register interceptors as singletons and resolve scoped dependencies from the DbContext's service provider.
4
ChangeTracker events are simpler and per-context, ideal for automatic property updates or lightweight reactions.
5
Test interceptors thoroughly with integration tests to ensure they don't introduce performance or recursion issues.

Common mistakes to avoid

3 patterns
×

Calling SaveChanges inside a SaveChanges interceptor

×

Registering interceptors as transient or scoped without considering lifetime

×

Using synchronous methods in async interceptors (e.g., .Result)

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how you would implement an audit trail using EF Core interceptor...
Q02SENIOR
What are the potential pitfalls of using interceptors in a high-traffic ...
Q03SENIOR
How would you handle a DbUpdateConcurrencyException using an interceptor...
Q01 of 03SENIOR

Explain how you would implement an audit trail using EF Core interceptors.

ANSWER
Implement ISaveChangesInterceptor. In SavingChangesAsync, iterate through ChangeTracker entries, capture entity state and property changes, and write audit logs to a separate table using a different DbContext to avoid recursion. Use AsyncLocal to prevent re-entry.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use both interceptors and events together?
02
How do I register an interceptor that depends on scoped services?
03
What is the difference between ISaveChangesInterceptor and ICommandInterceptor?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 13, 2026
last updated
2,043
articles · all by Naren
🔥

That's ASP.NET. Mark it forged?

3 min read · try the examples if you haven't

Previous
EF Core Performance Tuning
18 / 35 · ASP.NET
Next
EF Core Advanced Querying Patterns