Skip to main content

EF Core Entity States Explained: Detached, Added, Unchanged, Modified and Deleted

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

EF Core decides what SQL to send by looking at each entity's state. There are five states, and once you know them, SaveChangesAsync stops being mysterious. This guide follows one entity through all of them, printing the state at each step and showing the SQL each save sends.

The Five States​

EF Core's change tracking page defines them:

StateMeaningSaveChangesAsync sends
Detached"not being tracked by the DbContext"Nothing
Added"new and have not yet been inserted into the database"INSERT
Unchanged"have not been changed since they were queried from the database"Nothing
Modified"have been changed since they were queried from the database"UPDATE
DeletedExists in the database, and will be removedDELETE

Prerequisites​

  • A .NET C# project with EF Core. The examples use EF Core 10, Oracle's MySql.EntityFrameworkCore, MySQL 9.7, and a categories table.

Follow One Entity​

db.Entry(category).State returns the state of any object. This endpoint prints it after each step:

A temporary endpoint
app.MapPost("/demo/states", async (InventoryDbContext db, TimeProvider clock) =>
{
var now = clock.GetUtcNow().UtcDateTime;
var category = new Category { Name = $"States {now:HHmmssfff}", CreatedAtUtc = now };
var log = new List<string> { $"new object: {db.Entry(category).State}" };

db.Categories.Add(category);
log.Add($"after Add: {db.Entry(category).State}, Id={category.Id}");

await db.SaveChangesAsync();
log.Add($"after SaveChanges: {db.Entry(category).State}, Id={category.Id}");

category.Name += " renamed";
db.ChangeTracker.DetectChanges();
log.Add($"after changing Name: {db.Entry(category).State}");

await db.SaveChangesAsync();
log.Add($"after SaveChanges: {db.Entry(category).State}");

db.Categories.Remove(category);
log.Add($"after Remove: {db.Entry(category).State}");

await db.SaveChangesAsync();
log.Add($"after SaveChanges: {db.Entry(category).State}");

return Results.Text(string.Join('\n', log) + "\n");
});
Response
new object: Detached
after Add: Added, Id=0
after SaveChanges: Unchanged, Id=57
after changing Name: Modified
after SaveChanges: Unchanged
after Remove: Deleted
after SaveChanges: Detached

A terminal with one entity's states in order: Detached, Added with Id 0, Unchanged with Id 57, Modified, Unchanged, Deleted, Detached, next to the INSERT, UPDATE and DELETE statements that EF Core sent.

And the three saves sent three statements, one for each state that needed one:

API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p0='?' (DbType = DateTime), @p1='?' (Size = 100)], CommandType='Text', CommandTimeout='30']
INSERT INTO `categories` (`CreatedAtUtc`, `Name`)
VALUES (@p0, @p1);
SELECT `Id`
FROM `categories`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (Size = 100)], CommandType='Text', CommandTimeout='30']
UPDATE `categories` SET `Name` = @p0
WHERE `Id` = @p1;
SELECT ROW_COUNT();
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@p0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
DELETE FROM `categories`
WHERE `Id` = @p0;
SELECT ROW_COUNT();

After each save, the entity settles: Added and Modified become Unchanged, because the object now matches the database. A deleted one goes back to Detached, because there's nothing left to track.

What This Explains​

  • Why Add sends nothing. It only sets the state.
  • Why changing a loaded object is enough. EF Core compares it with the original values, and marks it Modified.
  • Why saving twice sends nothing the second time. After the first save, everything is Unchanged.
  • Why a Select result can't be saved. It's Detached, and it isn't even an entity.

Common Mistakes​

MistakeWhat happensDo this instead
Changing an object from AsNoTrackingIt's Detached, so nothing is savedLoad it with tracking
Setting State = Modified by hand on a whole entityEvery column gets writtenChange properties on a tracked entity
Reading the state right after a change, without DetectChangesIt can still say UnchangedCall DetectChanges, or SaveChangesAsync does it
Calling Remove and not savingThe row stays, and the state is only Deleted in memoryCall SaveChangesAsync

FAQ​

Does ExecuteUpdateAsync change any of this?​

No. ExecuteUpdateAsync and ExecuteDeleteAsync don't touch the change tracker at all, as this post shows, so a tracked object can end up different from its row.

How do I see all tracked entities?​

db.ChangeTracker.DebugView.LongView, as in this post.

Conclusion​

Every entity is Detached, Added, Unchanged, Modified or Deleted, and SaveChangesAsync sends SQL by state: INSERT for added, UPDATE for modified and DELETE for deleted. After a save, the object settles into Unchanged, or Detached if it was deleted. db.Entry(x).State tells you where any object stands.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.