Chapter 12 · Saving Data: Insert, Update, Delete, Batching, ExecuteUpdate, and ExecuteDelete
SaveChanges Pipeline, Command Ordering, Generated Keys, AcceptAllChanges, and Failure Semantics
Trace a ServiceHub SaveChanges call from change detection through dependency ordering, transactions, batching, generated-value propagation, acceptance, and failure-state diagnosis.
Learning outcomes
Chapter 11 stopped at the state manager: EF knew which ServiceHub entities were Added, Modified, or Deleted. This lesson follows those entries through the write pipeline until database commands either commit or fail. The practical question is not “does SaveChanges issue SQL?” but which SQL, in what dependency order, under which transaction, with which generated values, and what remains in memory if the call fails?
Trace DetectChanges, modification-command creation, dependency ordering, batching, transaction execution, generated-value propagation, and AcceptAllChanges.
Explain why principal inserts normally precede dependent inserts and dependent deletes precede principal deletes.
Observe generated SQLite keys and the ServiceHub Revision concurrency predicate in command logs.
Use SaveChanges(false) only when deliberately separating database success from tracker acceptance.
Diagnose DbUpdateException without assuming the ChangeTracker was rolled back to its pre-operation state.
Verify database atomicity separately from client-side entity state.
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. SQL Server/PostgreSQL native-loader examples are optional provider comparisons. EF Core 11 previews are excluded from the mandatory path.
1. SaveChanges is a pipeline, not one command
For a normal tracking context,
SaveChangesAsync first ensures tracked state is
current, turns entry/property state into provider modification
commands, orders commands so relational dependencies can be
satisfied, groups commands into provider batches where possible,
executes them, propagates database-generated values, and—unless
requested otherwise—accepts the resulting state as the new
tracker baseline.
| Stage | What EF is deciding | Evidence to capture |
|---|---|---|
| Change detection | Which tracked properties/relationships changed. | DebugView + IsModified before save. |
| Modification commands | Which INSERT/UPDATE/DELETE operations are required. | Database.Command logs. |
| Dependency ordering | Which command must precede another for keys/FKs. | Ordered SQL log plus FK constraints. |
| Transaction/batching | Which statements share atomic scope / round trip. | Transaction + command logs; provider docs. |
| Generated values | Keys/defaults/computed values returned from store. | EntityEntry values before/after save. |
| AcceptAllChanges | Whether Added/Modified/Deleted states become their accepted post-save states. | DebugView after save. |
2. A related insert shows command dependency ordering
Course continuity: Chapter 05 introduced
WorkOrderNote with private setters. Add this small
domain factory before the insert lab; it changes object
construction only and does not alter the relational schema.
public static WorkOrderNote Create(WorkOrder order, string text){ ArgumentNullException.ThrowIfNull(order); if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Note text is required.", nameof(text)); return new WorkOrderNote { WorkOrder = order, Text = text.Trim() };}
var order = WorkOrder.Open( "WO-2026-001201", "Inspect compressor vibration after restart", new ServiceAddress("21 Service Rd", "Baku", "AZ", "AZ1000", "AZ"));// WorkOrderNote already exists in the ServiceHub relationship model.var note = WorkOrderNote.Create( order, "Dispatch created the initial inspection note");order.Notes.Add(note);db.Add(order);Console.WriteLine($"Temporary before save: {db.Entry(order).Property(x => x.Id).IsTemporary}");Console.WriteLine(db.ChangeTracker.DebugView.LongView);await db.SaveChangesAsync(ct);Console.WriteLine($"Order key: {order.Id}");Console.WriteLine($"Temporary after save: {db.Entry(order).Property(x => x.Id).IsTemporary}");Console.WriteLine($"Order state: {db.Entry(order).State}");Console.WriteLine($"Note state: {db.Entry(note).State}");
The store-generated work-order key must be known before a dependent row can persist its foreign key. EF's update pipeline uses model relationships to order compatible commands. Do not depend on incidental statement ordering where no relational dependency exists; depend on constraints and transaction semantics instead.
INSERT INTO "work_orders" (...) VALUES (...) RETURNING "work_order_id";INSERT INTO "work_order_notes" ("work_order_id", "text") VALUES (@p_order, @p_text)RETURNING "work_order_note_id";
The exact SQL, parameter names, RETURNING usage, and whether statements share a batch are provider/version details; capture the actual log in the lab.
3. Tracked updates carry mapping and concurrency semantics
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);db.Entry(order).Property(x => x.Revision).OriginalValue = expectedRevision;order.ReviseSummary("Bearing inspection approved for dispatch");order.AdvanceRevision();await db.SaveChangesAsync(ct);
UPDATE "work_orders"SET "summary" = @p0, "revision" = @p1WHERE "work_order_id" = @id AND "revision" = @originalRevision;
Chapter 13 will go deeper on optimistic concurrency, but the write-pipeline fact is visible already: mapping metadata determines columns and the concurrency token contributes to the predicate. A zero-row update is not the same failure mechanism as a unique/FK constraint violation.
4. Default SaveChanges transaction behavior is database atomicity
For providers that support transactions, one
SaveChanges call is normally wrapped so all changes
in that call succeed or the database is rolled back. That
guarantee is about the database. It does not mean every
CLR object is magically reverted to old property values after an
exception.
var duplicate = WorkOrder.Open( existingWorkOrderNumber, // unique in the course model "Intentional duplicate for failure lab", new ServiceAddress("1 Lab St", "Baku", "AZ", "AZ1000", "AZ"));db.Add(duplicate);try{ await db.SaveChangesAsync(ct);}catch (DbUpdateException ex){ Console.WriteLine(ex.GetType().Name); Console.WriteLine(db.Entry(duplicate).State); // inspect; do not assume reset Console.WriteLine(db.ChangeTracker.DebugView.LongView);}
After the provider/database rejects the command, the failed
entity commonly remains pending in the tracker. Remove, detach,
repair, or abandon the short-lived context based on the error
class. Do not catch every DbUpdateException and
immediately retry unchanged state.
The duplicate-key experiment belongs only in servicehub-lab.db after a deterministic reset. Never use production data to “see what happens.”
5. SaveChanges(false) separates store success from tracker acceptance
order.ReviseSummary("Inspection complete; awaiting customer sign-off");order.AdvanceRevision();await db.SaveChangesAsync( acceptAllChangesOnSuccess: false, cancellationToken: ct);Console.WriteLine(db.Entry(order).State); // still ModifiedConsole.WriteLine(db.Entry(order).Property(x => x.Revision).OriginalValue);Console.WriteLine(db.Entry(order).Property(x => x.Revision).CurrentValue);db.ChangeTracker.AcceptAllChanges();Console.WriteLine(db.Entry(order).State); // Unchanged
The database operation may already be committed even though the
tracker has not accepted it. That is useful in advanced
durability protocols, but dangerous as a default: another
SaveChanges before acceptance can try to send the
same logical change again.
SaveChanges(false) is not a rollback switch and does not create a two-phase commit with external systems. Coordinate durability explicitly.
6. Hands-on lab: correlate tracker, commands, and store state
-
Reset the disposable SQLite database and enable
Microsoft.EntityFrameworkCore.Database.Commandand transaction logs. - Add one work order and one dependent note; record temporary/permanent keys and command order.
-
Modify Summary + Revision and compare
DebugViewwith the generated UPDATE predicate. - Delete a note and its parent in a disposable scenario; inspect delete ordering/cascade behavior from Chapter 05.
-
Trigger a duplicate unique value, capture
DbUpdateException, inspect tracker state, then verify the database did not partially apply the failed SaveChanges call. -
Repeat a successful modification with
SaveChangesAsync(false,...), then callAcceptAllChanges(). -
Run
dotnet ef migrations has-pending-model-changes; this lesson should require no schema change.
Check your understanding
- What does SaveChanges normally do before generating writes?
- Why can a principal INSERT precede a dependent INSERT?
- What does the default transaction protect?
- Does a DbUpdateException restore CLR properties?
- What does SaveChanges(false) suppress?
- What three evidence layers should be correlated?
Review the answers
It ensures tracked changes are detected so entity/property/relationship state is current.
The dependent FK may require the principal's generated key and relational constraints impose a dependency.
Database atomicity for the changes in that SaveChanges call when the provider supports transactions.
No. Inspect/repair or dispose the context; the database rollback and client tracker are separate concerns.
Automatic AcceptAllChanges after successful database execution.
ChangeTracker state, EF/provider command/transaction logs, and actual database rows/constraints.
7. Production judgment and bridge
Use tracked SaveChanges when domain logic, identity
generation, relationship ordering, optimistic concurrency, and a
unit-of-work boundary matter. Handle known database exceptions
deliberately and let unknown failures terminate the short-lived
context rather than mutating it blindly. Lesson 2 zooms in on
one pipeline stage—batching—and separates
statement count, parameter limits, and round trips from
marketing-language “bulk” claims.
Authoritative references
- Saving Data - EF Core — tracked SaveChanges versus ExecuteUpdate/Delete.
- Basic SaveChanges - EF Core — insert/update/delete and multiple operations.
- Using Transactions - EF Core — default atomic SaveChanges transaction behavior.
- Change Tracking - EF Core — state manager and SaveChanges interaction.
- DbContext.SaveChangesAsync API — acceptAllChangesOnSuccess overload and exceptions.