-
Notifications
You must be signed in to change notification settings - Fork 21
DbContext Lifetime and Change Tracking
How the bulk operations, temp tables, and from-query features of this library interact with EF Core's change tracker, and how to manage DbContext lifetime to avoid stale data, duplicate-tracking errors, and memory growth.
- TL;DR
- Bulk operations bypass the change tracker
-
When
ChangeTracker.Clear()actually matters - Temp tables and change tracking
- Bulk update / insert from a query
- Recommendations
-
Every bulk operation bypasses the change tracker. Bulk insert/update/upsert, temp-table inserts, and the from-query variants write straight to the database and return only the number of affected rows. They never
Add,Attach, or otherwise touch tracked entities. - Because of that,
ChangeTracker.Clear()is not required for a bulk operation to be correct. The samples call it between demo sections purely as a reset — "as an alternative to create a new one". - Clearing the tracker (or scoping a fresh
DbContext) only matters in the adjacent scenarios below: mixing bulk writes with regularSaveChangeswork, re-querying rows you just bulk-modified, and long-lived contexts that accumulate tracked entities. - For reads from temp tables, prefer keyless temp-table-specific entity classes — those are never tracked.
All bulk operations in this library write to the database directly, without involving EF Core's change tracker:
| Operation | SQL Server | PostgreSQL | SQLite |
|---|---|---|---|
| Bulk insert | SqlBulkCopy |
COPY (binary import) |
batched INSERT
|
| Bulk update | temp table + MERGE
|
UPDATE … FROM |
UPDATE |
| Bulk insert-or-update | temp table + MERGE
|
INSERT … ON CONFLICT |
INSERT … ON CONFLICT |
| Bulk update/insert from query |
UPDATE … FROM (…) / INSERT … SELECT
|
UPDATE … FROM (…) / INSERT … SELECT
|
UPDATE … FROM (…) / INSERT … SELECT
|
The entities you pass in are streamed to the server (as a data reader, COPY stream, or parameter batch) and are never associated with the DbContext. Each operation returns a Task<int> with the number of affected rows — there is no tracked result to clean up:
// Nothing here is tracked; the entities are written and forgotten.
var affected = await ctx.BulkInsertAsync(newEntities);
// Same for update / upsert.
await ctx.BulkUpdateAsync(changedEntities);
await ctx.BulkInsertOrUpdateAsync(entities);Remarks: This is the opposite of the regular
DbContext.Add+SaveChangesworkflow, where EF Core tracks every entity and generatesINSERT/UPDATEstatements from the tracked state. Bulk operations skip that machinery entirely, which is what makes them fast — and what makes the change tracker irrelevant to them.
The samples call ChangeTracker.Clear() after each demo step so one reused DbContext can stand in for many independent units of work:
await DoBulkInsertAsync(ctx);
ctx.ChangeTracker.Clear(); // resetting DbContext, as an alternative to create a new oneThat is hygiene between unrelated steps, not a requirement of the bulk operation. In real applications, prefer a short-lived DbContext per unit of work (the DI default is a scoped lifetime). You only need to think about the tracker in the three situations below.
Bulk operations write directly to the database, so any entity already tracked for the same rows becomes stale — the tracker has no idea the database changed underneath it. A later SaveChanges can then overwrite the bulk changes or throw a concurrency exception.
var order = await ctx.Orders.FirstAsync(o => o.Id == id); // tracked
await ctx.BulkUpdateAsync(new[] { someOrder }); // DB changed, tracker unaware
order.Note = "updated";
await ctx.SaveChangesAsync(); // writes based on STALE tracked stateIf you must combine both styles in one context, clear (or reload) the affected entities after the bulk write:
await ctx.BulkUpdateAsync(changedOrders);
ctx.ChangeTracker.Clear(); // drop stale tracked copies before further tracked workA normal (tracking) query reloads entities into the identity map. If an instance with the same key is already tracked, you either get the old, cached instance back instead of the fresh database values, or — when attaching — the classic "The instance of entity type 'X' cannot be tracked because another instance with the same key value is already being tracked."
Use AsNoTracking() for the read-back, or clear the tracker first:
await ctx.BulkInsertAsync(entities);
// Reads current DB state, ignores the identity map, tracks nothing:
var rows = await ctx.Entities.AsNoTracking().ToListAsync();A DbContext that lives a long time and runs many tracking queries accumulates tracked entities indefinitely. If you keep a context around across many operations (e.g. a console app or a worker loop), call ChangeTracker.Clear() between batches, or use AsNoTracking() for read-only queries.
Remarks:
DbContextis not thread-safe — never share one instance across concurrent operations. Use one context per unit of work; with bulk operations there is no tracked state to share anyway.
Whether rows read back from a temp table are tracked depends on the entity's key and the workflow you use:
| Scenario | Tracked? | Why |
|---|---|---|
| Keyless temp-table entity (the default) | No | Keyless entity types are never tracked by EF Core. |
Temp-table entity with a primary key, all-in-one (BulkInsertIntoTempTableAsync) |
No | The returned query has AsNoTracking() applied automatically. |
Temp-table entity with a primary key, two-step (CreateTempTableAsync + a manual query over the table) |
Yes, unless you add AsNoTracking()
|
The query you build yourself is a normal tracking query. |
The all-in-one workflow is safe by default:
// Inserts into a temp table and returns a query over it.
// If the temp-table entity has a PK, AsNoTracking() is already applied.
await using var tempTable = await ctx.BulkInsertIntoTempTableAsync(entities);
var joined = await ctx.Orders
.Join(tempTable.Query, o => o.Id, t => t.Id, (o, t) => o)
.ToListAsync();With the two-step workflow (CreateTempTableAsync to create an empty table, then BulkInsertValuesIntoTempTableAsync to fill it) you build the read query yourself. For a temp-table entity that has a primary key, add AsNoTracking() to that query when you don't want the results tracked.
The most robust option is to use keyless classes made specifically for temp tables instead of reusing your real entities — they are never tracked and never collide with real entities in the identity map. See Temp-Tables for details.
The from-query operations take an IQueryable<TSource> as the source. That query is translated to SQL and executed entirely server-side (UPDATE … FROM (subquery) / INSERT … SELECT); it is never materialized on the client. As a result:
- The source query's tracking behaviour is irrelevant — nothing is read into the context, so
AsNoTracking()on the source has no effect on tracking and the operation tracks nothing either. - The read and the write happen atomically in one database statement, so there are no client-side copies to go stale during the operation.
// Source is translated to SQL; no entities are materialized or tracked.
await ctx.Orders.BulkUpdateAsync(
ctx.Orders.Where(o => o.IsActive), // source IQueryable
target => target.Id, source => source.Id, // key selectors
setter => setter.Set(t => t.Count, (t, s) => t.Count + 1));The same staleness caveat as any bulk write applies afterwards: if you had already loaded some of those rows into the context before the operation, those tracked copies no longer reflect the database — clear or reload them before relying on them again. See Bulk Update from Query and Bulk Insert from Query.
- Prefer a short-lived
DbContextper unit of work. With DI the default scoped lifetime already gives you this. - Don't reach for
ChangeTracker.Clear()to "make bulk operations work" — they don't need it. Use it only to drop stale or accumulated tracked entities when you deliberately reuse a context. - When mixing bulk operations with regular tracked
SaveChangeswork in the same context, clear or reload affected entities after each bulk write. - Use
AsNoTracking()for read-only queries, and for two-step temp-table reads of entities that have a primary key. - Use keyless entity classes made specifically for temp tables rather than reusing real entities — see Temp-Tables.
- Never share a
DbContextacross threads.
- Collection Parameters (temp-tables light)
- Window Functions Support (RowNumber, Sum, Average, Min, Max)
- Nested (virtual) Transactions
- Table Hints
- Queries across multiple databases
- Changing default schema at runtime
- If-Exists / If-Not-Exists checks in migrations
- Isolation of tests [DEPRECATED]