AI WisdomArchitecture & guides β†—
HT
How Things Work

DbContext & the Change Tracker

EF Core's Unit of Work β€” how it tracks every entity state change before writing a single byte to the database.

How It Works

The DbContext is EF Core's central object β€” it represents a session with the database, manages the identity map, and acts as the Unit of Work by collecting all entity changes and flushing them atomically. Every entity you load or add gets registered in the change tracker with a snapshot of its original values. When SaveChanges fires, EF diffs current vs. original, generates the minimum SQL needed, and executes it in one transaction.

1
DbContext Opens a Connection

When you first execute a query or call SaveChanges, EF Core opens a connection from the connection pool. The DbContext holds this connection for its lifetime β€” which in ASP.NET Core is one HTTP request via scoped DI.

2
Identity Map: One Instance Per Key

The change tracker maintains an identity map. If you load Order #42 twice in the same DbContext lifetime, you get the exact same C# object reference β€” not two separate copies. This prevents phantom updates but can hide stale data bugs.

3
Snapshot-Based Change Detection

On load, EF Core captures a snapshot of every tracked entity's original property values. When SaveChanges is called, it calls DetectChanges() which diffs the current values against the snapshot to find Modified properties.

4
State Machine Transition

Each entity moves through states: Detached β†’ Added/Unchanged β†’ Modified/Deleted. EF generates exactly the SQL needed for each state β€” INSERT for Added, UPDATE for Modified (only changed columns), DELETE for Deleted, nothing for Unchanged.

5
SaveChanges Flushes in One Transaction

All pending changes are written in a single database transaction. If any statement fails, the entire batch rolls back. EF Core 9 returns the count of affected rows and raises SavedChanges / SaveChangesFailed events for observability.

Key Concepts

πŸ”΅EntityState

Enum with 5 values: Detached, Unchanged, Added, Modified, Deleted. EF generates SQL based on this state at SaveChanges time.

πŸ‘Change Tracker

The internal mechanism that watches every tracked entity. Stores original values (snapshot) and current values. Drives DetectChanges().

πŸ“¦Unit of Work

Design pattern: collect all changes during a business operation, then flush them atomically. DbContext IS your unit of work.

πŸ”DetectChanges()

Scans all tracked entities comparing current vs. snapshot values. Called automatically before SaveChanges. Expensive in loops β€” never call it manually per-entity.

⚑AsNoTracking()

Bypasses the identity map and change tracking entirely. 30-40% faster for read-only queries. Entities returned cannot be directly SaveChanges'd.

πŸ’ΎSaveChangesAsync

Async flush. Calls DetectChanges, validates entities, generates SQL, executes in a transaction, updates entity states back to Unchanged.

DbContext & Change Tracker β€” real patterns
tsx
1// The DbContext is your Unit of Work β€” it tracks EVERYTHING
2public class AppDbContext : DbContext
3{
4 public DbSet<Order> Orders { get; set; }
5 public DbSet<Customer> Customers { get; set; }
6
7 // EF Core 9: SaveChangesAsync with CancellationToken support
8 public override async Task<int> SaveChangesAsync(
9 CancellationToken cancellationToken = default)
10 {
11 // Inspect what's about to be written β€” great for audit logs
12 var entries = ChangeTracker.Entries()
13 .Where(e => e.State is EntityState.Added or EntityState.Modified);
14
15 foreach (var entry in entries)
16 {
17 if (entry.Entity is IAuditableEntity auditable)
18 auditable.UpdatedAt = DateTime.UtcNow;
19 }
20
21 return await base.SaveChangesAsync(cancellationToken);
22 }
23}
24
25// Reading entities β€” tracked by default
26var order = await _dbContext.Orders.FindAsync(orderId, cancellationToken);
27order.Status = OrderStatus.Shipped; // Change Tracker sees this
28
29// The SQL EF generates:
30// UPDATE Orders SET Status = 2, UpdatedAt = '...' WHERE Id = @id
31// Only CHANGED columns are included β€” not SELECT *
32await _dbContext.SaveChangesAsync(cancellationToken);
33
34// For read-only queries: skip the change tracker overhead
35var reportData = await _dbContext.Orders
36 .AsNoTracking()
37 .Where(o => o.CreatedAt >= cutoff)
38 .ToListAsync(cancellationToken);
39
40// Check before flushing
41if (_dbContext.ChangeTracker.HasChanges())
42 await _dbContext.SaveChangesAsync(cancellationToken);
πŸ’‘
Why This Matters

The DbContext's change tracking is what separates EF Core from a raw SQL mapper. It means you can modify entities in plain C# and SaveChanges generates precise SQL touching only changed columns. But it's also the source of the most production bugs: non-thread-safe shared instances, stale identity map data, and O(nΒ²) DetectChanges in bulk operations. Understanding the state machine and lifetime model prevents entire categories of data corruption.

Common Pitfalls

⚠DbContext is NOT thread-safe. Sharing one instance across Task.WhenAll threads will throw 'InvalidOperationException: A second operation was started on this context instance before a previous operation completed' β€” or worse, silently corrupt data.
⚠Calling DetectChanges() manually in a loop that already has hundreds of tracked entities causes O(n) work per call. EF calls it automatically before SaveChanges β€” you almost never need to call it yourself.
⚠Using AsNoTracking() on entities you plan to later attach and modify/delete is a trap. The entity's state is Detached and EF won't generate the right SQL unless you explicitly call _dbContext.Update(entity) β€” which sends UPDATE for ALL columns, not just changed ones.
⚠Long-lived DbContext instances (singleton, stored in a field) serve data from the identity map cache. The identity map never expires. Your query might return 10-minute-old data even though the database was updated by another process.
Real-World Use Cases

1Silent Data Corruption from Shared DbContext

Scenario

We had a background service that processed orders in parallel using Task.WhenAll. Intermittently, order statuses were being saved with wrong values β€” a 'Shipped' order would revert to 'Pending'.

Problem

The service was registering DbContext as a singleton. Two Task threads were sharing the same DbContext instance simultaneously. Thread A loaded Order #1 and Thread B loaded Order #2 β€” but the identity map and change tracker are not thread-safe. Thread B's SaveChanges flushed Thread A's partial changes in a corrupted state. The actual exception we eventually saw: 'InvalidOperationException: A second operation was started on this context instance before a previous operation completed.'

Solution

Register DbContext as scoped (default in ASP.NET Core). For background services, use IDbContextFactory<AppDbContext> to create a fresh DbContext per task: 'using var context = _factory.CreateDbContext();'. Each parallel operation gets complete isolation.

πŸ’‘

Takeaway: DbContext is explicitly documented as not thread-safe. One instance per request/operation, never shared across threads. IDbContextFactory is the canonical solution for parallel work.

2Long-Lived DbContext Serving Stale Data

Scenario

Our API was returning stale inventory counts. Another service had decremented stock 10 minutes ago, but the GET endpoint was still returning the old values. No caching was involved.

Problem

The DbContext was registered as a singleton (accidentally, via a DI configuration mistake). The identity map was returning the cached in-memory entity from the first load β€” never re-querying the database. The data in the identity map was 10 minutes old.

Solution

Fix the lifetime to scoped. For cases where you genuinely need to re-read fresh data within the same context, call '_dbContext.Entry(entity).ReloadAsync(cancellationToken)' to force a re-fetch, or use AsNoTracking() with a fresh query. Also added a DbContext lifetime assertion in integration tests.

πŸ’‘

Takeaway: The identity map is a feature, not a bug β€” but it means a long-lived DbContext is effectively a cache of your database state. Short-lived, per-request DbContext instances are the intended design.

3DetectChanges Killing Performance in a Bulk Import

Scenario

Importing 50,000 product records took 4 minutes. Profiling showed 98% of CPU time inside EF's DetectChanges method, which was being called thousands of times.

Problem

The import loop was calling SaveChanges() after every entity insert. Each SaveChanges() call triggers DetectChanges(), which scans ALL tracked entities. By record 40,000, there were 40,000 tracked entities to scan β€” O(nΒ²) behavior.

Solution

Either call SaveChanges every 500 records and clear the tracker ('_dbContext.ChangeTracker.Clear()'), use AddRange() for batching, or switch to ExecuteBulkInsert via EFCore.BulkExtensions for genuine bulk scenarios. Also set AutoDetectChangesEnabled = false during import and call DetectChanges() once before the batch SaveChanges.

πŸ’‘

Takeaway: DetectChanges is O(n) per tracked entity. Combine that with calling SaveChanges() per record and you get O(nΒ²). Always batch inserts and clear the change tracker when doing bulk operations.