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.
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.
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.
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.
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.
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.
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
Enum with 5 values: Detached, Unchanged, Added, Modified, Deleted. EF generates SQL based on this state at SaveChanges time.
The internal mechanism that watches every tracked entity. Stores original values (snapshot) and current values. Drives DetectChanges().
Design pattern: collect all changes during a business operation, then flush them atomically. DbContext IS your unit of work.
Scans all tracked entities comparing current vs. snapshot values. Called automatically before SaveChanges. Expensive in loops β never call it manually per-entity.
Bypasses the identity map and change tracking entirely. 30-40% faster for read-only queries. Entities returned cannot be directly SaveChanges'd.
Async flush. Calls DetectChanges, validates entities, generates SQL, executes in a transaction, updates entity states back to Unchanged.
1// The DbContext is your Unit of Work β it tracks EVERYTHING2public class AppDbContext : DbContext3{4 public DbSet<Order> Orders { get; set; }5 public DbSet<Customer> Customers { get; set; }67 // EF Core 9: SaveChangesAsync with CancellationToken support8 public override async Task<int> SaveChangesAsync(9 CancellationToken cancellationToken = default)10 {11 // Inspect what's about to be written β great for audit logs12 var entries = ChangeTracker.Entries()13 .Where(e => e.State is EntityState.Added or EntityState.Modified);1415 foreach (var entry in entries)16 {17 if (entry.Entity is IAuditableEntity auditable)18 auditable.UpdatedAt = DateTime.UtcNow;19 }2021 return await base.SaveChangesAsync(cancellationToken);22 }23}2425// Reading entities β tracked by default26var order = await _dbContext.Orders.FindAsync(orderId, cancellationToken);27order.Status = OrderStatus.Shipped; // Change Tracker sees this2829// The SQL EF generates:30// UPDATE Orders SET Status = 2, UpdatedAt = '...' WHERE Id = @id31// Only CHANGED columns are included β not SELECT *32await _dbContext.SaveChangesAsync(cancellationToken);3334// For read-only queries: skip the change tracker overhead35var reportData = await _dbContext.Orders36 .AsNoTracking()37 .Where(o => o.CreatedAt >= cutoff)38 .ToListAsync(cancellationToken);3940// Check before flushing41if (_dbContext.ChangeTracker.HasChanges())42 await _dbContext.SaveChangesAsync(cancellationToken);
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
1Silent Data Corruption from Shared DbContext
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'.
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.'
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
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.
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.
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
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.
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.
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.