Chapter 11 · Change Tracking, Entity States, Identity Resolution, and Disconnected Graphs
Identity Resolution, Tracking vs No-Tracking, and AsNoTrackingWithIdentityResolution
Compare tracking, no-tracking, and no-tracking-with-identity-resolution by CLR reference identity, ChangeTracker state, fixup behavior, memory tradeoffs, and update boundaries.
Learning outcomes
A reporting query joins one work order to several tags. The
relational result repeats the work-order columns once per tag.
Should the client receive one CLR WorkOrder object
reused three times, or three separate objects with the same
primary key? The answer depends on
tracking behavior and
identity resolution, which are related but not
identical.
Define identity resolution as one CLR instance per entity key within a tracking scope.
Compare tracking, AsNoTracking, and AsNoTrackingWithIdentityResolution using duplicate relational rows.
Inspect ReferenceEquals results and context ChangeTracker counts rather than guessing.
Explain navigation fixup, update semantics, memory costs, and serialization implications.
Reproduce the duplicate-instance InvalidOperationException when two same-key instances are attached.
Choose a query tracking mode based on whether the result is an editable unit of work or a read model.
Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, and no paid tooling. EF Core 11 preview APIs are not part of the mandatory path.
1. Identity resolution solves an ambiguity, not a SQL problem
A DbContext can track only one entity instance for
a given primary-key value. If two different objects both claim
to be WorkOrder Id=42, EF would not know which
property values or relationships represent the unit of work.
Identity resolution therefore maps repeated
rows/keys to one CLR instance within the relevant tracker.
A tracking query is sent to the database. If the returned key is already tracked, EF returns the existing instance rather than overwriting its current/original values with the newly returned database values. Use Reload/GetDatabaseValues when you intentionally need store refresh evidence.
2. Build a duplicate-row query from the existing tag association
IQueryable<WorkOrder> DuplicatedOrders(ServiceHubContext db, int id) => db.Set<WorkOrderTag>() .Where(link => link.WorkOrderId == id) .OrderBy(link => link.DisplayOrder) .Select(link => link.WorkOrder);
If the work order has three tag links, the SQL can return three rows representing the same work-order key. This is ideal for observing materialization identity without adding a fake schema just for the lesson.
3. Tracking: one context instance per key
await using var trackingDb = await factory.CreateDbContextAsync(ct);var rows = await DuplicatedOrders(trackingDb, id).ToListAsync(ct);Console.WriteLine($"Rows: {rows.Count}");Console.WriteLine($"Same CLR instance: {ReferenceEquals(rows[0], rows[1])}");Console.WriteLine($"Tracked WorkOrders: {trackingDb.ChangeTracker.Entries<WorkOrder>().Count()}");
Repeated database rows can appear multiple times in the result
list, but each occurrence refers to the same tracked
WorkOrder object. Navigation fixup can then connect
this object to other tracked related entities. Any later
detected modifications can participate in
SaveChanges.
4. AsNoTracking: database-shaped instances, no context state
await using var noTrackDb = await factory.CreateDbContextAsync(ct);var rows = await DuplicatedOrders(noTrackDb, id) .AsNoTracking() .ToListAsync(ct);Console.WriteLine($"Same CLR instance: {ReferenceEquals(rows[0], rows[1])}");Console.WriteLine($"Context tracked entries: {noTrackDb.ChangeTracker.Entries().Count()}");
For ordinary no-tracking entity queries, repeated entity rows can materialize as distinct CLR objects. This often suits read-only streaming/serialization because the context does not retain snapshots and state, but duplicate instances can increase allocation and can surprise reference-equality logic.
5. AsNoTrackingWithIdentityResolution: temporary identity map, no unit-of-work tracking
await using var identityDb = await factory.CreateDbContextAsync(ct);var rows = await DuplicatedOrders(identityDb, id) .AsNoTrackingWithIdentityResolution() .ToListAsync(ct);Console.WriteLine($"Same CLR instance: {ReferenceEquals(rows[0], rows[1])}");Console.WriteLine($"Context tracked entries: {identityDb.ChangeTracker.Entries().Count()}");
EF uses a stand-alone tracker while enumerating the result so each key is materialized once, but those entities are not attached to the context's normal change tracker. After enumeration, the temporary identity-resolution tracker can be discarded.
| Mode | Identity resolution | Tracked by DbContext | Typical fit |
|---|---|---|---|
| Tracking | Yes | Yes | Editable unit of work; relationship fixup. |
| AsNoTracking | No | No | Simple read model/stream where duplicate instances are acceptable. |
| AsNoTrackingWithIdentityResolution | Yes, per query enumeration | No | Read graph with repeated entity keys where reference reuse matters. |
6. Deliberately wrong: attach a second same-key object
var tracked = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);var duplicate = WorkOrder.RehydrateForAssignment(id, tracked.Revision);try{ db.Attach(duplicate);}catch (InvalidOperationException ex){ Console.WriteLine(ex.Message);}
The expected mechanism is not a database unique-key error. It is
a client-side identity-map conflict: this context already tracks
another WorkOrder with the same key. Repair by
using the already tracked instance, or start a fresh unit of
work—not by detaching arbitrary entities until the exception
disappears.
7. Tracking queries can return existing stale client values
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);order.ReviseSummary("Unsaved local draft");// The query still goes to the database, but identity resolution returns// the already tracked WorkOrder instance for the same key.var queriedAgain = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);Console.WriteLine(ReferenceEquals(order, queriedAgain)); // trueConsole.WriteLine(queriedAgain.Summary); // local tracked valuevar databaseValues = await db.Entry(order).GetDatabaseValuesAsync(ct);
This is a feature of a unit of work: current client state is not silently overwritten by another query. It is also why long-lived contexts become stale and why a fresh context is usually the right read boundary.
8. Projections can still track entities
var rows = await db.WorkOrders .Select(w => new { Entity = w, NoteCount = w.Notes.Count }) .ToListAsync(ct);Console.WriteLine(db.ChangeTracker.Entries<WorkOrder>().Count());
Returning an anonymous/DTO-like outer object does not automatically mean “no tracking” if the projection contains entity instances. A scalar-only projection with no entity object has nothing to track. Be explicit at read boundaries.
9. Hands-on lab: prove reference identity three ways
-
Seed one work order with at least three
WorkOrderTaglinks. - Run the duplicate-row query in three fresh contexts: tracking, no-tracking, and no-tracking-with-identity-resolution.
-
Record list count,
ReferenceEquals(rows[0], rows[1]), and context tracker count. -
In a tracking context, modify the returned instance and
confirm
SaveChangessees it. - In a no-tracking context, modify the returned object and confirm no write occurs until you deliberately attach/update it.
- Trigger the duplicate same-key attach exception and capture the exact message.
Check your understanding
- What invariant does identity resolution enforce in a DbContext?
- Does AsNoTracking perform ordinary identity resolution?
- What does AsNoTrackingWithIdentityResolution add?
- Does a tracking query overwrite local tracked current values with database values?
- Can a projection still cause tracking?
- Why is attaching two same-key instances rejected?
Review the answers
At most one tracked CLR entity instance for a given entity type/key.
No; repeated rows can materialize as distinct CLR objects.
A stand-alone tracker for per-query identity reuse without attaching results to the DbContext tracker.
No; when the key is already tracked, the existing instance is reused. Reload/GetDatabaseValues are explicit refresh tools.
Yes, if the projection contains entity instances and the query is tracking.
EF would have ambiguous property and relationship state for one database identity.
10. Production judgment and bridge
Choose tracking because the unit of work will edit entities—not because it is the default. Choose ordinary no-tracking for read models where duplicate object identity is irrelevant. Add identity resolution only when repeated entity keys materially affect result consistency/allocation. Lesson 4 moves across a process/request boundary where the original tracker is gone and the application must reconstruct write intent safely.
Authoritative references
- Tracking vs. No-Tracking Queries - EF Core — tracking behavior, identity resolution, projections.
- Identity Resolution - EF Core — one-instance-per-key rule and duplicate-instance failures.
- How Queries Work - EF Core — materialization and tracker lookup during query execution.
- Accessing Tracked Entities - EF Core — GetDatabaseValues and entry inspection.
- DbContext Lifetime, Configuration, and Initialization — short-lived unit-of-work rationale.