DbContext Lifetime and Change Tracking
Objective
A DbContext is a unit of work: it tracks the entities you load, notices what
changed, and writes the difference on SaveChanges. Most EF Core bugs that
look random come from misunderstanding two things about it. First, how long an
instance lives and who shares it, because it is not thread-safe and it
accumulates every entity it has seen. Second, what the change tracker does with
each entity, because that decides what SQL is sent, what memory is held, and
whether a query returns the object you already modified.
Use Cases
- Registering
OrdersDbContextin an ASP.NET Core app so each request gets its own instance and never shares it with another request. - Using a
DbContextfrom aBackgroundServiceor a Blazor Server component, where there is no request scope to borrow. - Making a read-only list endpoint cheaper by not tracking what it loads.
- Understanding why a second query for the same row returns the instance you already changed, not what is in the database.
Deep Dive
One context per unit of work
AddDbContext registers the context as scoped, so in a web app that means one
instance per request. That matches the unit-of-work idea: load, change, save,
dispose. A DbContext is not thread-safe, so running two queries on the same
instance at once throws:
plaintext// Wrong: two operations on one context at the same time. var a = db.Orders.ToListAsync(ct); var b = db.Customers.ToListAsync(ct); await Task.WhenAll(a, b); // InvalidOperationException: A second operation started on this context before a previous operation completed // Right: await each one, or give each task its own context from a factory. var orders = await db.Orders.ToListAsync(ct); var customers = await db.Customers.ToListAsync(ct);
Pooling and factories
AddDbContextPool keeps a pool of instances and resets their state between
uses, which removes the cost of building one per request. The price is a
rule: a pooled context must not hold per-request state in its own fields,
because the instance outlives the request. For code that has no scope, inject
IDbContextFactory<T> and create a short-lived context where you need it:
plaintextbuilder.Services.AddDbContextPool<OrdersDbContext>(o => o.UseNpgsql(connectionString)); builder.Services.AddPooledDbContextFactory<ReportsDbContext>(o => o.UseNpgsql(connectionString)); internal sealed class NightlyJob(IDbContextFactory<ReportsDbContext> factory) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { await using var db = await factory.CreateDbContextAsync(ct); // new context per run // ... query and save } }
A BackgroundService is a singleton, so injecting a scoped DbContext into it
fails at startup, or worse, captures one instance for the lifetime of the app.
What the change tracker records
Every tracked entity has a state: Detached, Unchanged, Added, Modified,
or Deleted. A query that returns entities starts them as Unchanged and
keeps a snapshot of their values. On SaveChanges, EF Core compares each
entity with its snapshot (DetectChanges), and only the properties that
differ go into the UPDATE:
plaintextvar order = await db.Orders.SingleAsync(o => o.Id == id, ct); order.Rename("Rush order"); // Modified, only the changed column is written await db.SaveChangesAsync(ct); // UPDATE orders SET name = @p0 WHERE id = @p1 var state = db.Entry(order).State; // Unchanged again after the save
The tracker also gives identity resolution: asking for the same key twice returns the same instance, and the second query does not overwrite what you already changed in memory.
Stop tracking when you only read
Tracking costs memory and CPU, because every entity gets a snapshot. For a
read-only query, AsNoTracking skips it, and a projection into a DTO skips
entities entirely:
plaintextvar rows = await db.Orders .AsNoTracking() .Where(o => o.CustomerId == customerId) .Select(o => new OrderRow(o.Id.Value, o.Status, o.Total.Amount)) .ToListAsync(ct);
For long-lived contexts, such as a batch import that loads thousands of rows,
call db.ChangeTracker.Clear() between batches so the tracker does not keep
every entity until the end.
Trade-offs
- Pooling reuses instances, so leaked state leaks across requests. A field
set on the context in one request can be visible in the next.plaintext
public sealed class OrdersDbContext : DbContext { public Guid CurrentTenant { get; set; } // dangerous in a pooled context } // Resolve the tenant from a scoped service inside the query filter instead. AsNoTrackingqueries return disconnected entities. Changing them and callingSaveChangeswrites nothing, because the context never saw them.plaintextvar o = await db.Orders.AsNoTracking().SingleAsync(x => x.Id == id, ct); o.Rename("X"); await db.SaveChangesAsync(ct); // 0 rows affected, no error- No-tracking queries lose identity resolution. Two rows that point at the
same customer produce two separate
Customerobjects unless you useAsNoTrackingWithIdentityResolution, which costs part of the saving. - A long-lived context grows without bound and goes stale. Keeping one context for a whole session of a desktop or Blazor Server app holds every entity it ever loaded and shows data that other users changed since.
DetectChangesis linear in the number of tracked entities. Tracking tens of thousands of entities makes everySaveChangesslow, which is another reason to batch and clear instead of loading everything at once.