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.

Intermediate120–150 minutesentity-state + SaveChanges labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Define the five EntityState values and connect each state to write behavior.

02

Observe state transitions caused by queries, Add, Attach, Update, Remove, SaveChanges, AcceptAllChanges, and Detach.

03

Inspect temporary/generated key values without assuming a particular temporary integer.

04

Connect Modified/Deleted states to representative SQLite SQL and the existing Revision concurrency predicate.

05

Explain SaveChanges(false) versus AcceptAllChanges and why neither is a database rollback.

06

Repair the common “call Update so EF notices” mistake with tracked intent and property-level evidence.

Reproducible baseline

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

csharp · state transitions in the ServiceHub context
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.

Do not execute this whole snippet as one save

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.

csharp · inspect key state without magic numbers
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

csharp · tracked intent before save
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);
sql · representative SQLite UPDATE shape
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

csharp · delete lifecycle
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.

csharp · two-phase state acceptance
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
Not a rollback switch

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

csharp · broad disconnected update
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.

csharp · repair: query one tracked aggregate and apply intent
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

csharp · detach one entry
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

  1. Reset servicehub-lab.db with the existing migration/seed workflow.
  2. Query one work order and print DebugView.ShortView.
  3. Create one new work order and inspect Id.IsTemporary before/after save.
  4. Modify Summary + Revision, call DetectChanges, list IsModified properties, and capture the UPDATE command log.
  5. Delete one disposable note and verify Deleted → Detached after normal save.
  6. Repeat one modification with SaveChangesAsync(false,...), inspect state, then call AcceptAllChanges().
  7. Run dotnet ef migrations has-pending-model-changes; Chapter 11 should not require a schema change.

Check your understanding

  1. What does Unchanged mean?
  2. Why can an Added integer key be temporary?
  3. What normally happens to a Deleted entry after a successful accepted save?
  4. Does SaveChanges(false) roll back database work?
  5. Why is Update not a substitute for DetectChanges?
  6. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n