Chapter 11 · Change Tracking, Entity States, Identity Resolution, and Disconnected Graphs
Unchanged, Added, Modified, Deleted, and Detached: The Entity-State Machine
Expose EF Core entity states as the client-side write state machine: query/Add/Attach/Update/Remove transitions, generated keys, SaveChanges, AcceptAllChanges, and detach behavior.
Learning outcomes
A dispatcher edits a ServiceHub work order, adds a note, removes
an obsolete attachment, and calls SaveChangesAsync.
Four different SQL commands may follow even though application
code never called INSERT, UPDATE, or
DELETE directly. The missing mental model is EF
Core's state manager: every tracked entity
entry has a state, property snapshots, relationship information,
and key metadata that drive persistence.
Define the five EntityState values and connect each state to write behavior.
Observe state transitions caused by queries, Add, Attach, Update, Remove, SaveChanges, AcceptAllChanges, and Detach.
Inspect temporary/generated key values without assuming a particular temporary integer.
Connect Modified/Deleted states to representative SQLite SQL and the existing Revision concurrency predicate.
Explain SaveChanges(false) versus AcceptAllChanges and why neither is a database rollback.
Repair the common “call Update so EF notices” mistake with tracked intent and property-level evidence.
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. The state machine is local to one DbContext
A tracked entity is a CLR object known to one
DbContext. EF stores an
EntityEntry for it. The entry's
EntityState tells EF whether that row is believed
to be absent, unchanged, changed, scheduled for deletion, or not
tracked at all. State is not a database lock and it is not
shared across contexts.
| State | Meaning inside this context | Normal SaveChanges effect |
|---|---|---|
Detached |
No tracking entry participates in this unit of work. | No write from this instance. |
Unchanged |
Tracked and believed equal to its original snapshot. | No DML for this entry. |
Added |
New row should be inserted. | INSERT; store-generated values flow back. |
Modified |
One or more mapped properties are marked changed. | UPDATE for mapped modified values, subject to provider/mapping. |
Deleted |
Existing row is scheduled for removal. | DELETE, subject to relationships/concurrency. |
Queries returning keyed entities are tracking by default.
Therefore the most common transition is database query →
Unchanged → domain mutation →
Modified after change detection →
Unchanged again after a successful save accepts
changes.
2. Observe query, Add, Attach, Update, and Remove
await using (var queriedDb = await factory.CreateDbContextAsync(ct)){ var tracked = await queriedDb.WorkOrders.SingleAsync( x => x.WorkOrderNumber == "WO-2026-000201", ct); Console.WriteLine(queriedDb.Entry(tracked).State); // Unchanged}await using (var addDb = await factory.CreateDbContextAsync(ct)){ var added = WorkOrder.Open( "WO-2026-001101", "Inspect chilled-water pump vibration", new ServiceAddress("100 Plant Way", "Baku", "AZ", "AZ1000", "AZ")); addDb.Add(added); Console.WriteLine(addDb.Entry(added).State); // Added}await using (var attachDb = await factory.CreateDbContextAsync(ct)){ var stub = WorkOrder.RehydrateForAssignment(existingId, existingRevision); attachDb.Attach(stub); Console.WriteLine(attachDb.Entry(stub).State); // Unchanged}await using (var updateDb = await factory.CreateDbContextAsync(ct)){ var stub = WorkOrder.RehydrateForAssignment(existingId, existingRevision); updateDb.Update(stub); Console.WriteLine(updateDb.Entry(stub).State); // Modified}await using (var deleteDb = await factory.CreateDbContextAsync(ct)){ var tracked = await deleteDb.WorkOrders.SingleAsync(x => x.Id == deleteId, ct); deleteDb.Remove(tracked); Console.WriteLine(deleteDb.Entry(tracked).State); // Deleted}
Add, Attach, and
Update also traverse reachable graphs according to
EF tracking rules. That is why the same method can mark more
than the root entity. Chapter 11 Lesson 4 returns to this graph
behavior for disconnected API payloads.
It is an observation exercise containing mutually conflicting operations on teaching instances. Run each transition independently against a reset disposable database.
3. Temporary keys distinguish “new” before the database answers
For generated keys, EF can assign a temporary value to a newly tracked entry so relationships can point to it before the database returns the permanent key. Do not write logic that depends on the exact temporary integer; inspect metadata instead.
var entry = db.Entry(added);var key = entry.Property(x => x.Id);Console.WriteLine($"State: {entry.State}");Console.WriteLine($"Tracked key value: {key.CurrentValue}");Console.WriteLine($"Temporary: {key.IsTemporary}");await db.SaveChangesAsync(ct);Console.WriteLine($"Permanent key: {added.Id}");Console.WriteLine($"Temporary now: {db.Entry(added).Property(x => x.Id).IsTemporary}");Console.WriteLine($"State after accepted save: {db.Entry(added).State}");
SQLite's generated integer key is provider/store behavior; EF's
temporary-key bookkeeping is client tracking behavior. After a
normal successful save, the permanent key is propagated and the
entry becomes Unchanged.
4. Modified properties become an UPDATE, including concurrency evidence
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);var before = order.Revision;order.ReviseSummary("Inspect suction pressure and cavitation alarm");order.AdvanceRevision();db.ChangeTracker.DetectChanges();var entry = db.Entry(order);Console.WriteLine(entry.State); // Modifiedforeach (var property in entry.Properties.Where(p => p.IsModified)) Console.WriteLine($"Modified: {property.Metadata.Name}");await db.SaveChangesAsync(ct);
UPDATE "work_orders"SET "summary" = @p0, "revision" = @p1WHERE "work_order_id" = @p2 AND "revision" = @p3RETURNING 1;
The old Revision is the concurrency predicate and
the new revision is written as a value. The exact parameter
names and SQL formatting are provider/version details; the
important evidence is which columns are written and which
original values appear in the predicate.
5. Remove marks Deleted; accepted deletion ends tracking
var note = await db.Set<WorkOrderNote>().FirstAsync(ct);Console.WriteLine(db.Entry(note).State); // Unchangeddb.Remove(note);Console.WriteLine(db.Entry(note).State); // Deletedawait db.SaveChangesAsync(ct);Console.WriteLine(db.Entry(note).State); // Detached after AcceptAllChanges
For an entity that was already persisted, a successful normal
save causes a deleted entry to become Detached.
Relationship cascade/orphan rules from Chapter 05 can cause
additional entries to become deleted; inspect the full
ChangeTracker.DebugView rather than assuming only
the explicit root changed.
6. SaveChanges(false) writes but does not accept state
The overload with
acceptAllChangesOnSuccess: false is an advanced
control point. It tells EF to execute the database save but not
call ChangeTracker.AcceptAllChanges() automatically
afterward. Store-generated values can still be propagated, but
original snapshots/states are intentionally not finalized.
order.ReviseSummary("Pump inspection approved for dispatch");order.AdvanceRevision();await db.SaveChangesAsync( acceptAllChangesOnSuccess: false, cancellationToken: ct);Console.WriteLine(db.Entry(order).State); // still Modified// External durable work could happen here only with a carefully designed protocol.db.ChangeTracker.AcceptAllChanges();Console.WriteLine(db.Entry(order).State); // Unchanged
SaveChanges(false) does not undo a committed database command. Calling SaveChanges again before accepting can repeat writes; for Added rows it can even attempt another insert. Use this overload only when you understand the surrounding transaction/durability protocol.
7. Deliberately wrong: Update as a “make EF notice” button
var posted = WorkOrder.RehydrateForAssignment(command.WorkOrderId, command.Revision);// imagine many scalar values were also deserialized/defaulteddb.Update(posted); // broad graph/state declarationawait db.SaveChangesAsync(ct);
Update is not change detection. It is an explicit
state declaration over a graph. Existing mapped properties can
be marked modified even when the client did not intend to edit
them, creating over-posting and lost-data risk.
var order = await db.WorkOrders.SingleAsync(x => x.Id == command.WorkOrderId, ct);db.Entry(order).Property(x => x.Revision).OriginalValue = command.Revision;order.ReviseSummary(command.Summary);order.AdvanceRevision();await db.SaveChangesAsync(ct);
The repair trades one read for explicit authorization, validation, original concurrency state, and narrow modified-property evidence.
8. Detach is explicit, but short context lifetime is the default cleanup
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);Console.WriteLine(db.Entry(order).State); // Unchangeddb.Entry(order).State = EntityState.Detached;Console.WriteLine(db.Entry(order).State); // Detached
Detaching can be useful in diagnostics or specialized workflows,
but normal application code should usually dispose the
short-lived context. ChangeTracker.Clear() is more
efficient than detaching every entry one-by-one when a
deliberate bulk clear is required.
9. Hands-on lab: print the state machine and SQL together
-
Reset
servicehub-lab.dbwith the existing migration/seed workflow. -
Query one work order and print
DebugView.ShortView. -
Create one new work order and inspect
Id.IsTemporarybefore/after save. -
Modify Summary + Revision, call
DetectChanges, listIsModifiedproperties, and capture the UPDATE command log. -
Delete one disposable note and verify
Deleted → Detachedafter normal save. -
Repeat one modification with
SaveChangesAsync(false,...), inspect state, then callAcceptAllChanges(). -
Run
dotnet ef migrations has-pending-model-changes; Chapter 11 should not require a schema change.
Check your understanding
- What does Unchanged mean?
- Why can an Added integer key be temporary?
- What normally happens to a Deleted entry after a successful accepted save?
- Does SaveChanges(false) roll back database work?
- Why is Update not a substitute for DetectChanges?
- What evidence should accompany a state claim?
Review the answers
The context tracks the entity and currently believes its mapped values match the original snapshot; it does not mean the database row can never change externally.
EF needs a key value for client-side identity/relationships before the database returns the generated permanent value.
It becomes Detached because the row was deleted and the unit of work accepts that result.
No. It suppresses automatic AcceptAllChanges; it is not a database rollback.
Update explicitly marks an entity/graph as modified according to graph rules; DetectChanges compares tracked state and snapshots to discover actual changes.
EntityEntry/DebugView state, IsModified flags, generated command logs/SQL, and resulting database state.
10. Production judgment and bridge
Let short-lived tracking contexts express a real unit of work.
Prefer query → domain mutation → save for ordinary updates; use
explicit state APIs only when the disconnected boundary requires
them. Do not use SaveChanges(false), broad
Update, or manual detach as ritual. Lesson 2 now
asks a subtler question: when does EF actually notice a normal
CLR property mutation, and what happens when automatic change
detection is disabled?
Authoritative references
- Change Tracking - EF Core — tracked entity lifecycle and entity states.
- Explicitly Tracking Entities - EF Core — Add/Attach/Update and generated-key graph behavior.
- Accessing Tracked Entities - EF Core — EntityEntry, states, property metadata, temporary values.
- Basic SaveChanges - EF Core — insert/update/delete behavior and graph notes.
- ChangeTracker.AcceptAllChanges API — explicit state acceptance after a save.