Skip to content

DbContext Lifetime and Change Tracking

Pawel Gerr edited this page Jun 18, 2026 · 1 revision

Required Nuget Package:
Thinktecture.EntityFrameworkCore.SqlServer
Thinktecture.EntityFrameworkCore.PostgreSQL
Thinktecture.EntityFrameworkCore.Sqlite

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

  • 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 regular SaveChanges work, 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.

Bulk operations bypass the change tracker

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 + SaveChanges workflow, where EF Core tracks every entity and generates INSERT/UPDATE statements 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.

When ChangeTracker.Clear() actually matters

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 one

That 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.

Mixing bulk operations with SaveChanges

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 state

If 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 work

Re-querying rows you just bulk-modified

A 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();

Long-lived contexts and memory growth

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: DbContext is 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.

Temp tables and change tracking

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.

Bulk update / insert from a query

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.

Recommendations

  • Prefer a short-lived DbContext per 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 SaveChanges work 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 DbContext across threads.

Clone this wiki locally