What this usually means
EF Core's change tracker maintains a first-level cache of entities it has queried. When you query an entity by its primary key, EF returns the cached instance from the change tracker without hitting the database, even if the underlying row was changed by another process, a direct SQL command, or even a previous DbContext instance. The tracker doesn't know about external modifications. This is by design for performance, but it causes stale reads when you assume every query goes to the database.
The first ten minutes — establish facts before touching code.
- 1Add AsNoTracking() to the failing query and check if fresh data appears. If yes, change tracker caching is the cause.
- 2Call context.Entry(entity).ReloadAsync() after the update and compare old vs new values.
- 3Check if the update was done via ExecuteSqlRaw or a different DbContext instance — the tracker won't see it.
- 4Verify the entity's primary key value and that the same instance is still attached by inspecting context.ChangeTracker.Entries().
- 5Inspect EntityEntry.State: if it's Unchanged after a database modification, you have a stale cache.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchDbContext.OnConfiguring or AddDbContext service registration for query tracking settings
- searchLINQ queries that don't call AsNoTracking() or AsNoTrackingWithIdentityResolution()
- searchCode that uses ExecuteSqlRaw, ExecuteSqlInterpolated, or stored procedures to modify data
- searchEntity classes with [NotMapped] properties or computed columns that might confuse the tracker
- searchDbContext lifetime — if scoped per request, the same context sees the same cache
- searchAny code that detaches then re-attaches entities manually without refreshing
- searchDatabase logs or SQL Profiler traces showing no SELECT after an update
Practical causes, not theory. These are the things you will actually find.
- warningUsing ExecuteSqlRaw to update a row that the context already tracked — tracker doesn't invalidate the cache
- warningSharing a long-lived DbContext across multiple requests or background jobs
- warningManually setting entity property values without calling SaveChanges and re-querying
- warningAttaching a detached entity with stale values without setting state to Modified
- warningEntity has a database-generated computed column that the tracker didn't refresh after SaveChanges
- warningUsing AsNoTracking on the initial query but tracking on subsequent queries — the tracker never had the fresh data
Concrete fix directions. Pick the one that matches your root cause.
- buildAlways use AsNoTracking() for read-only queries that don't need change tracking — this avoids caching side effects.
- buildAfter making changes via ExecuteSqlRaw, call context.ChangeTracker.Clear() before the next tracked query.
- buildUse context.Entry(entity).ReloadAsync() to force a database refresh for a specific tracked entity.
- buildSet the entity's state to Detached before re-querying: context.Entry(entity).State = EntityState.Detached;
- buildFor API endpoints, use short-lived DbContext instances (AddDbContext with ServiceLifetime.Scoped) to minimize stale cache windows.
- buildWrap updates in a transaction and use raw SQL with OUTPUT clause to read fresh values in one roundtrip.
A fix you cannot prove is a guess. Close the loop.
- verifiedAdd a log statement showing EntityEntry.State and original values before and after the problematic query.
- verifiedWrite a test that updates a row via raw SQL, then queries with tracking and asserts the new value is returned (should fail before fix).
- verifiedAfter applying the fix, run the same test and confirm the fresh value is read.
- verifiedMonitor SQL Server Profiler or EF Core logs to confirm a SELECT is executed when you expect fresh data.
- verifiedRemove AsNoTracking() from a previously working query and verify it still returns fresh data after the fix.
Things that make this bug worse or harder to find.
- warningCalling context.Entry(entity).ReloadAsync() in a loop — it hits the database every time; batch refresh instead.
- warningSetting entity state to Modified without knowing original values — EF will send all properties in UPDATE.
- warningUsing AsNoTrackingWithIdentityResolution() without understanding its behavior: it still caches identity but not tracking.
- warningAssuming SaveChanges refreshes navigation properties or computed columns — it only updates the entity's own properties.
- warningCalling context.Dispose() and creating a new context for every query — that's a performance anti-pattern but does fix stale data.
- warningIgnoring the DbContext lifetime scope — a singleton context will accumulate stale entities over time.
Stale Order Status After Direct SQL Update
Timeline
- 09:15Pager alert: Order status API returns 'Pending' for order #12345, but database shows 'Shipped'.
- 09:18Checked SQL Server directly: SELECT Status FROM Orders WHERE Id=12345 returns 'Shipped'.
- 09:22Reproduced API call manually via Swagger — still 'Pending'.
- 09:25Added AsNoTracking() to query in controller — suddenly returns 'Shipped'. Confirmed change tracker caching.
- 09:30Found that a background job updates order status via ExecuteSqlRaw on a separate DbContext instance.
- 09:35Inspected the controller's DbContext: it had queried the order earlier in the request and tracked it.
- 09:40Applied fix: called context.ChangeTracker.Clear() before the tracked query in the controller.
- 09:42Verified API now returns 'Shipped'. Deployed fix.
The initial symptom was maddening: the order status endpoint consistently returned 'Pending' for an order that had clearly been shipped. I checked the database directly — definitely 'Shipped'. The API was returning stale data, but only for some orders. I added logging and saw that the EF Core query wasn't even hitting the database. The change tracker had cached the entity from an earlier read and never invalidated it.
I traced the flow: a background job ran every minute, calling ExecuteSqlRaw to update order status directly. That job used its own DbContext instance. Meanwhile, the main API controller used a scoped DbContext per request. On a request, the controller first loaded the order (tracked), then later the background job updated it. When the controller's same DbContext queried again, it returned the cached 'Pending' entity.
The fix was simple: call context.ChangeTracker.Clear() before re-querying after a known external modification. In my case, I cleared the tracker right before the GET endpoint returned the order. I also added a note to the team: any raw SQL update on entities loaded earlier in the same request needs to either use Reload or clear the tracker. The lesson: never assume the change tracker is aware of changes made outside its context.
Root cause
EF Core change tracker cached the entity after an initial tracked query; a background job updated the row via ExecuteSqlRaw on a different DbContext; the original DbContext's tracker wasn't notified of the change, so subsequent queries returned the stale cached entity.
The fix
Called context.ChangeTracker.Clear() before re-querying the order in the controller; also added AsNoTracking() to read-only endpoints to avoid caching altogether.
The lesson
EF Core change tracker is not a real-time cache; it's an identity map. For any data modified outside the current DbContext (raw SQL, other contexts, external processes), you must invalidate the cache explicitly or use AsNoTracking().
When EF Core executes a tracked query, it materializes entities and stores them in the change tracker's internal dictionary keyed by primary key. Subsequent queries for the same key return the cached instance without a database roundtrip. This behavior is by design: it ensures that within a single DbContext, you always work with the same entity instance, avoiding concurrency conflicts.
However, this caching means that any modification to the underlying database row — whether from another DbContext, a direct SQL command, or another application — is invisible to the tracker. The entity's OriginalValues remain unchanged, and its State remains Unchanged. The only way to detect external changes is to explicitly refresh the entity or disable tracking.
The quickest diagnostic tool is the EntityEntry API. After a suspected stale read, call context.Entry(entity) and inspect its State, OriginalValues, and CurrentValues. If the database has been modified, OriginalValues will still hold the old values, and State will be Unchanged. Compare this to a fresh query with AsNoTracking() to see the actual database state.
You can also enable logging to see if EF Core generates a SELECT statement. Add optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) to your DbContext configuration. A missing SELECT after an update confirms the cache hit.
Rule of thumb: use AsNoTracking() for all read-only queries. This avoids populating the tracker unnecessarily and eliminates stale cache issues. For queries that need tracking (e.g., for updates), keep them short-lived and within the same unit of work.
If you must use raw SQL updates (ExecuteSqlRaw) on entities that might be tracked, call context.ChangeTracker.Clear() immediately after the SQL execution. This detaches all tracked entities, forcing the next query to hit the database. Alternatively, use ReloadAsync on the specific entity if you need to keep other tracked entities intact.
EF Core 5+ introduced AsNoTrackingWithIdentityResolution, which still performs identity resolution (returns the same instance for the same key) but does not track changes. This can cause stale data if the entity was already resolved earlier in the query. It's not a silver bullet for external modifications — it only prevents the tracker from storing state, but the identity map within the query can still serve stale instances if the entity was already materialized in the same query.
For external modifications, always use AsNoTracking (without identity resolution) or clear the tracker. The identity resolution in a no-tracking query is per query, not per context lifetime, so it's safer but not perfect.
Calling ChangeTracker.Clear() detaches all tracked entities. If you have many tracked entities, this is a cheap operation (O(n) where n is number of tracked entities). However, it means any pending changes not yet saved are lost. Always call SaveChanges before clearing if you have unsaved changes.
An alternative is to selectively detach only the stale entity: context.Entry(entity).State = EntityState.Detached. This is more granular but requires you to know which entity is stale. In high-throughput scenarios, prefer short-lived DbContext instances (scoped per request) to avoid accumulating tracked entities.
Frequently asked questions
Does calling SaveChanges refresh the tracked entity from the database?
No. SaveChanges only writes pending changes to the database. It does not re-read the entity. If the database has computed columns or triggers that modify values, you need to call ReloadAsync or use the DatabaseGenerated(Computed) attribute with a subsequent SELECT.
Why does AsNoTracking() return fresh data but tracked query return stale?
AsNoTracking() bypasses the change tracker entirely, so every query goes to the database. A tracked query first checks the tracker's cache; if the entity is already cached (by primary key), it returns that instance without a database call. That's why adding AsNoTracking is a quick diagnostic to confirm caching.
Can I make EF Core always hit the database for a specific entity?
Yes, you can call context.Entry(entity).ReloadAsync() to force a database refresh for a tracked entity. However, this still requires a roundtrip. For a more permanent solution, use AsNoTracking() for queries where you don't need change tracking, or clear the tracker before re-querying.
Does EF Core's second-level cache (e.g., via third-party libraries) solve this?
Second-level caches operate outside the change tracker and can be configured to have expiration policies. However, they introduce additional complexity and are not a direct fix for the change tracker's identity map. The simplest approach is to manage tracking explicitly.
What about entity-level concurrency tokens like RowVersion?
RowVersion can help detect stale data during updates (Optimistic Concurrency), but it does not prevent stale reads. The change tracker still caches the entity; the RowVersion is just another property. Only when you attempt to update will EF check the RowVersion. For reads, you must still use AsNoTracking or refresh.