Updated Jun 23, 2026

Change Tracking & SaveChanges

Here's the thing that trips up almost everyone the first time: there is no Update method you call to save an edit. You load a Post, you change its Title, you call SaveChanges, and the UPDATE statement just... appears. That can feel like magic, and magic you don't understand is magic that will bite you. So before any code, let's nail the mental model.

💡 The mental model. A DbContext is a unit of work with a built-in change tracker. The moment a query hands you an entity, the context starts watching it: it stashes a private snapshot of every property value as they were when loaded. When you call SaveChanges, EF compares the current values of each tracked entity against its snapshot, works out the minimal set of inserts, updates, and deletes needed, and pushes them all to the database in one transaction. You don't tell EF what changed — it figures that out by diffing.

Hold that picture — load, mutate, diff, save — and everything in this phase follows from it.

Update by mutation

Because the context already tracks the entity, updating a row is three steps: load it, change a property, save. No "update" call.

using var ctx = new BlogContext();

var post = ctx.Posts.First(p => p.Id == 42);
post.Title = "Rewritten and better";
ctx.SaveChanges();

What just happened: The First query loaded the post and registered it with the change tracker, snapshot included. Setting post.Title only changed the in-memory object — nothing hit the database yet. SaveChanges then diffed the entity against its snapshot, saw that exactly one column differed, and emitted SQL touching only that column:

UPDATE Posts SET Title = 'Rewritten and better' WHERE Id = 42;

📝 Notice it does not rewrite every column — only Title. EF tracks changes per-property, so an UPDATE carries just the columns that actually moved. That keeps writes small and avoids stomping on columns another process may have touched.

Deleting is the same shape, except you tell the context to mark the entity for removal:

var post = ctx.Posts.First(p => p.Id == 42);
ctx.Posts.Remove(post);
ctx.SaveChanges();

What just happened: Remove doesn't delete anything immediately — it flips the tracked entity's state to Deleted. SaveChanges is what actually runs DELETE FROM Posts WHERE Id = 42;. Until you save, the row is still there.

Entity states: what the tracker is really storing

Every entity the context knows about sits in exactly one of five states. This is the vocabulary the change tracker thinks in:

State Meaning What SaveChanges does
Added New, not yet in the DB (you called Add) INSERT
Unchanged Loaded and untouched since nothing
Modified A tracked property changed UPDATE (changed columns)
Deleted Marked for removal (you called Remove) DELETE
Detached The context isn't tracking it at all nothing — it's invisible

You can read or set the state yourself through ctx.Entry(...):

var post = ctx.Posts.First(p => p.Id == 42);
Console.WriteLine(ctx.Entry(post).State);   // Unchanged

post.Title = "Edited";
Console.WriteLine(ctx.Entry(post).State);   // Modified

What just happened: Fresh from the query, the post is Unchanged. The instant you mutate a tracked property, the change tracker notices (it detects this when you inspect state or call SaveChanges) and moves the entity to Modified. You never set this by hand in the normal flow — the diff drives it. ctx.Entry(post).State is your window into what the tracker believes about any entity.

💡 Want to see the whole picture? ctx.ChangeTracker.Entries() returns every tracked entity with its state — a great thing to dump when an update mysteriously does nothing (foreshadowing).

SaveChanges: one batch, one transaction

SaveChanges isn't a per-entity operation. It collects all pending changes across every tracked entity, then sends them together, wrapped in a single database transaction.

ctx.Posts.Add(new Post { Title = "Brand new" });        // Added
ctx.Posts.First(p => p.Id == 7).Title = "Touched up";   // Modified
ctx.Posts.Remove(ctx.Posts.First(p => p.Id == 9));      // Deleted

int rows = ctx.SaveChanges();
Console.WriteLine($"{rows} rows affected");              // 3 rows affected

What just happened: Three different operations — an insert, an update, and a delete — accumulated in the tracker. The single SaveChanges call issued all three inside one transaction: if any statement fails, the whole batch rolls back and your database is left untouched. The return value is the number of rows affected, which here is 3. That all-or-nothing guarantee is exactly why the unit-of-work pattern exists.

⚠️ The detached-entity trap — the #1 web-app surprise

Everything above assumes the entity was loaded by this context, so it's tracked. In a web app, that assumption quietly breaks, and this is the single most common EF Core gotcha you will hit.

When a controller receives an object deserialized from a JSON request body, that object was created by the model binder — not loaded by your DbContext. It is Detached. The context has never seen it, has no snapshot for it, and isn't watching it. So this does nothing:

// post came from the HTTP request body — it is DETACHED
public IActionResult UpdatePost(Post post)
{
    post.Title = "Changed in the controller";
    ctx.SaveChanges();   // ⚠️ no UPDATE runs — the context isn't tracking `post`
    return Ok();
}

What just happened: Nothing, and that's the trap. post is detached, so there's no snapshot to diff and no tracked state to mark Modified. SaveChanges looks at its (empty) change tracker, finds nothing to do, and returns 0. No error, no exception — just a silent no-op that looks like a bug in your database. People lose hours here.

You have three honest ways to fix it.

1. ctx.Update(...) — mark the whole entity Modified. Simplest, but it sets every column to Modified, so the UPDATE rewrites all columns regardless of what actually changed.

ctx.Update(post);    // attaches as Modified (all properties)
ctx.SaveChanges();   // UPDATE Posts SET Title=..., Body=..., ... WHERE Id = post.Id

What just happened: Update attaches the detached object to the context and stamps it Modified wholesale. Now SaveChanges has something to write. The cost: you overwrite every column from whatever values are on post — including any the client didn't intend to change.

2. Attach + set state explicitly. When you want finer control over which properties are dirty.

ctx.Attach(post);
ctx.Entry(post).Property(p => p.Title).IsModified = true;   // only Title
ctx.SaveChanges();

What just happened: Attach brings post in as Unchanged (a baseline). Then you mark just Title as modified, so the resulting UPDATE touches only that one column — the same minimal-write behavior you'd get from the tracked path.

3. Load-then-copy. The safest pattern, and what most production code does: load the real tracked entity, copy the allowed fields onto it, then save.

var existing = ctx.Posts.First(p => p.Id == post.Id);   // tracked, with snapshot
existing.Title = post.Title;                            // copy only what you allow
ctx.SaveChanges();                                      // normal diff-and-UPDATE

What just happened: You're back on the happy path. existing is tracked, so the change tracker diffs it normally and writes only the columns you copied. This also guards against a client smuggling in fields you never meant to expose, since you control exactly which properties get copied.

⚠️ If an update "works locally but does nothing in the web app," your entity is almost certainly detached. Check ctx.Entry(entity).State — if it says Detached, that's your answer.

Recap

  • A DbContext is a unit of work with a change tracker: it snapshots every entity it loads and diffs against that snapshot on save.
  • You don't call an update method. Load → mutate a property → SaveChanges, and EF emits an UPDATE for only the changed columns. Delete with Remove.
  • Every tracked entity has a stateAdded, Unchanged, Modified, Deleted, or Detached — which you can inspect or set via ctx.Entry(e).State.
  • SaveChanges batches all pending inserts/updates/deletes into one transaction and returns the number of affected rows.
  • Detached entities (objects from a web request, not loaded by this context) aren't tracked — mutating them does nothing on save. Fix with ctx.Update(...), Attach + per-property state, or the safer load-then-copy.

Quick check

[
  {
    "q": "You load a Post, set post.Title, and call SaveChanges. What SQL does EF Core generate?",
    "choices": ["Nothing — you forgot to call an Update method", "An UPDATE touching only the Title column", "An UPDATE rewriting every column on the row", "An INSERT for a new Post"],
    "answer": 1,
    "explain": "The entity is tracked, so SaveChanges diffs it against its snapshot, sees only Title changed, and emits an UPDATE for just that column."
  },
  {
    "q": "A controller receives a Post deserialized from the request body, sets a property, and calls SaveChanges. Nothing changes in the database. Why?",
    "choices": ["The transaction rolled back", "The object is detached, so the context isn't tracking it and has nothing to save", "SaveChanges only handles inserts", "You must call Remove first"],
    "answer": 1,
    "explain": "An object from the request body was never loaded by this context, so it's Detached. With no snapshot and no tracked state, SaveChanges finds nothing to do — a silent no-op."
  },
  {
    "q": "What does SaveChanges return?",
    "choices": ["The saved entity", "true if any change was written", "The number of rows affected", "The new primary key"],
    "answer": 2,
    "explain": "SaveChanges batches all pending changes into one transaction and returns the count of rows affected."
  }
]

← Phase 4: Querying with LINQ · Guide overview · Phase 6: Relationships →

Check your understanding

1. You load a Post, set post.Title, and call SaveChanges. What SQL does EF Core generate?

2. A controller receives a Post deserialized from the request body, sets a property, and calls SaveChanges. Nothing changes in the database. Why?

3. What does SaveChanges return?

Was this page helpful?