Are you sure SaveChanges()
is doing what you think it is? Most beginners accidentally overwrite fields, miss concurrency conflicts, or load way too much data – and pay for it later. In this post, you’ll master add, update, and delete operations in Entity Framework Core with practical, copy‑paste‑ready examples and pitfalls I learned the hard way.
Quick start: project and model
Let’s set up the smallest possible solution to play with.
# create
mkdir EfCoreCrudDemo && cd EfCoreCrudDemo
dotnet new console -n EfCoreCrudDemo
cd EfCoreCrudDemo
# add EF Core (SQLite provider)
dotnet add package Microsoft.EntityFrameworkCore --version 8.*
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 8.*
dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.*
Create a simple model with a concurrency token and a soft‑delete flag.
// Models/TodoItem.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace EfCoreCrudDemo.Models;
public class TodoItem
{
public int Id { get; set; }
[Required, MaxLength(200)]
public string Title { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
// Soft delete
public bool IsDeleted { get; set; }
// Audit
[MaxLength(100)]
public string? CreatedBy { get; set; }
public DateTime CreatedAtUtc { get; set; }
public DateTime? UpdatedAtUtc { get; set; }
// Concurrency (SQL Server uses rowversion; SQLite can still use this as a token)
[Timestamp]
public byte[]? Version { get; set; }
}
DbContext with a global query filter (hides soft‑deleted rows), default values, and simple auditing.
// Data/AppDbContext.cs
using EfCoreCrudDemo.Models;
using Microsoft.EntityFrameworkCore;
namespace EfCoreCrudDemo.Data;
public class AppDbContext : DbContext
{
public DbSet<TodoItem> Todos => Set<TodoItem>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Soft delete: hide deleted rows by default
modelBuilder.Entity<TodoItem>().HasQueryFilter(t => !t.IsDeleted);
// Index for faster lookups
modelBuilder.Entity<TodoItem>()
.HasIndex(t => new { t.IsCompleted, t.IsDeleted });
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
ApplyAuditValues();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
ApplyAuditValues();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
private void ApplyAuditValues()
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<TodoItem>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAtUtc = now;
entry.Entity.CreatedBy = entry.Entity.CreatedBy ?? "system";
}
if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAtUtc = now;
}
}
}
}
Wire up SQLite.
// Program.cs
using EfCoreCrudDemo.Data;
using Microsoft.EntityFrameworkCore;
var builder = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=todo.db")
.EnableSensitiveDataLogging(); // dev only
using var db = new AppDbContext(builder.Options);
await db.Database.EnsureCreatedAsync();
Console.WriteLine("Database ready.");
Tip:
EnsureCreated
is great for demos. Use Migrations in real apps:dotnet ef migrations add Init && dotnet ef database update
.
Add: inserting data the safe way
Basic insert
var todo = new TodoItem { Title = "Learn EF Core" };
db.Todos.Add(todo); // or AddAsync in ASP.NET Core endpoints
await db.SaveChangesAsync();
Console.WriteLine($"Inserted Todo #{todo.Id}");
Add
marks the entity asAdded
. AfterSaveChanges
, EF sets the generatedId
.- Use
AddRange
to insert multiple items efficiently.
var items = new []
{
new TodoItem { Title = "Write unit tests" },
new TodoItem { Title = "Ship v1" }
};
db.Todos.AddRange(items);
await db.SaveChangesAsync();
Insert with relationships (quick sketch)
If you had TodoList
→ TodoItem
, adding items via the parent navigation ensures correct foreign keys. The DbContext
assigns FKs when both entities are tracked.
Update: tracked vs disconnected
Most bugs happen during updates. The key is to know whether the entity is tracked by the current DbContext
.
Tracked update (simple)
var todo = await db.Todos.FirstAsync(t => t.Id == 1);
todo.IsCompleted = true; // EF marks IsCompleted as modified
await db.SaveChangesAsync();
- Because the entity was loaded by this
DbContext
, EF knows exactly which properties changed.
Disconnected update (typical Web API)
You got a DTO from the client, but you don’t want to overwrite fields you didn’t receive.
public record UpdateTodoDto(bool? IsCompleted, string? Title);
public static async Task PatchTodoAsync(AppDbContext db, int id, UpdateTodoDto dto)
{
var stub = new EfCoreCrudDemo.Models.TodoItem { Id = id };
db.Attach(stub); // now in Unchanged state
if (dto.IsCompleted is not null)
db.Entry(stub).Property(t => t.IsCompleted).CurrentValue = dto.IsCompleted.Value,
db.Entry(stub).Property(t => t.IsCompleted).IsModified = true;
if (!string.IsNullOrWhiteSpace(dto.Title))
db.Entry(stub).Property(t => t.Title).CurrentValue = dto.Title!,
db.Entry(stub).Property(t => t.Title).IsModified = true;
await db.SaveChangesAsync();
}
Why this pattern?
Update(entity)
marks all properties as modified and can overwrite data you didn’t intend to change.- Attaching a stub with only the key, then marking specific properties as modified, is the safest way to perform partial updates.
In my last project, switching from
Update()
to selectiveIsModified
eliminated a class of “lost updates” where optional fields were being nulled by accident.
Replace-all update (explicit)
If you genuinely want to replace everything the client sends, use Update()
but validate your DTO first.
public static async Task PutTodoAsync(AppDbContext db, int id, UpdateTodoDto dto)
{
var entity = new EfCoreCrudDemo.Models.TodoItem
{
Id = id,
Title = dto.Title ?? string.Empty,
IsCompleted = dto.IsCompleted ?? false
};
db.Update(entity); // marks all properties as modified
await db.SaveChangesAsync();
}
Bulk update: ExecuteUpdate
(no roundtrip)
EF Core 7+ can generate UPDATE
statements without loading entities.
// Mark all overdue tasks as completed (example condition)
await db.Todos
.Where(t => !t.IsDeleted && !t.IsCompleted && t.Title.Contains("overdue"))
.ExecuteUpdateAsync(setters => setters
.SetProperty(t => t.IsCompleted, t => true)
.SetProperty(t => t.UpdatedAtUtc, t => DateTime.UtcNow));
- No tracking, no per‑row materialization – fast.
- Concurrency tokens aren’t checked here; if you need them, load/track or use a predicate that includes the token.
Handling concurrency with rowversion
/Timestamp
When two users edit the same row, the last writer can overwrite the first. Concurrency tokens fix this.
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Option A: Client wins — refresh the token and retry
foreach (var entry in ex.Entries)
{
await entry.ReloadAsync();
}
// Re-apply client changes if needed, then retry SaveChanges
}
Other strategies:
- Store‑wins: reload entity and show the user the latest values.
- Merge: load database values, compute a diff, and reapply only non‑conflicting fields.
Hint: expose the
Version
value in your API responses and require it on updates. Reject requests where the token doesn’t match.
Delete: hard, soft, and bulk
Hard delete
var todo = await db.Todos.FirstAsync(t => t.Id == 2);
db.Todos.Remove(todo);
await db.SaveChangesAsync();
For many rows:
db.Todos.RemoveRange(await db.Todos.Where(t => t.IsCompleted).ToListAsync());
await db.SaveChangesAsync();
Bulk delete: ExecuteDelete
await db.Todos.Where(t => t.IsCompleted && !t.IsDeleted).ExecuteDeleteAsync();
- Generates a single
DELETE
SQL statement. - Bypasses change tracking (no audit hooks in
SaveChanges
). If you need audit, load and remove or use a trigger.
Soft delete (global filter)
We already added IsDeleted
and a global filter. Implement the operation:
public static async Task SoftDeleteAsync(AppDbContext db, int id)
{
var stub = new EfCoreCrudDemo.Models.TodoItem { Id = id };
db.Attach(stub);
db.Entry(stub).Property(t => t.IsDeleted).CurrentValue = true;
db.Entry(stub).Property(t => t.IsDeleted).IsModified = true;
await db.SaveChangesAsync();
}
To include deleted rows in admin pages:
var all = await db.Todos.IgnoreQueryFilters().ToListAsync();
Change Tracker (and perf tips)
EntityState
:Detached
,Unchanged
,Added
,Modified
,Deleted
.AsNoTracking()
: returns entities not tracked (great for read‑only queries).AsNoTrackingWithIdentityResolution()
: still avoids tracking, but ensures same identity maps to the same reference during materialization (handy for graphs).AutoDetectChangesEnabled = false
: micro‑opt for batch operations when you manually control changes.
Inspect current states
foreach (var e in db.ChangeTracker.Entries())
{
Console.WriteLine($"{e.Entity.GetType().Name} => {e.State}");
}
Selective updates are your friend: prefer Entry.Property(...).IsModified
over Update()
for partial patches.
Transactions and SaveChanges variations
EF Core wraps SaveChanges
in a transaction per call. For multi‑step workflows, use an explicit transaction.
await using var tx = await db.Database.BeginTransactionAsync();
var a = new TodoItem { Title = "Atomic A" };
var b = new TodoItem { Title = "Atomic B" };
db.AddRange(a, b);
await db.SaveChangesAsync();
// More work here...
await tx.CommitAsync();
acceptAllChangesOnSuccess
await db.SaveChangesAsync(acceptAllChangesOnSuccess: false);
// Do something…
db.ChangeTracker.AcceptAllChanges();
This advanced pattern helps when you need windowed error handling before accepting changes.
Validation & constraints (catch errors early)
- Data Annotations like
[Required]
,[MaxLength]
,[Range]
validate before hitting the database. - Unique constraints? Add an index with
IsUnique()
and handleDbUpdateException
for duplicates.
modelBuilder.Entity<TodoItem>()
.HasIndex(t => t.Title)
.IsUnique();
try
{
db.Todos.Add(new TodoItem { Title = "Learn EF Core" });
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
Console.WriteLine("A todo with this title already exists.");
}
Disconnected patterns that scale (APIs)
In ASP.NET Core minimal APIs, project to DTOs for reads and use selective updates for writes.
// Read (no tracking, fast)
app.MapGet("/todos", async (AppDbContext db) =>
{
return await db.Todos
.AsNoTracking()
.OrderByDescending(t => t.CreatedAtUtc)
.Select(t => new { t.Id, t.Title, t.IsCompleted })
.ToListAsync();
});
// Patch (partial)
app.MapPatch("/todos/{id:int}", async (int id, UpdateTodoDto dto, AppDbContext db) =>
{
await PatchTodoAsync(db, id, dto);
return Results.NoContent();
});
Why project? Returning full entities couples your API shape to the database and leaks columns you might add later. Projection gives stable contracts and better performance.
Troubleshooting: common exceptions
DbUpdateConcurrencyException
: You updated a row that changed since you loaded it. Include and verify the concurrency token (e.g.,Version
).DbUpdateException
/ constraint violation: Check unique keys and FK constraints. LogInnerException
for provider details.- “The instance of entity type cannot be tracked because another instance with the same key value is already being tracked”: You attached a duplicate. Detach the old one or use
AsNoTracking
on reads when you plan to attach a stub for updates. - Nothing saved: Did you forget to call
SaveChanges
? Or is the entityUnchanged
? InspectChangeTracker
.
Mini cheat sheet (bookmark this)
- Add:
Add/AddRange
→SaveChanges
- Update (tracked): load → mutate →
SaveChanges
- Update (disconnected):
Attach(stub)
→ set specific props → markIsModified
- Bulk:
ExecuteUpdate/ExecuteDelete
- Soft delete:
IsDeleted
+HasQueryFilter
- Concurrency:
Timestamp/rowversion
+ handleDbUpdateConcurrencyException
- Perf:
AsNoTracking
for reads, project to DTOs, avoidUpdate()
unless you truly replace all fields
FAQ: EF Core data operations for beginners
For EF Core, DbContext
is a repository/unit of work. A thin, purpose‑built abstraction is okay, but generic repositories often add friction and hide EF features.
AsNoTracking
? Use for read‑only queries or when you’ll attach a stub later to update. It reduces memory and speeds up queries.
ExecuteUpdate
safe for business logic? It’s safe for set‑based updates, but it bypasses SaveChanges
hooks and tracking. If you rely on auditing or validation in SaveChanges
, you must implement it differently (e.g., database triggers or manual updates of audit columns).
Require a concurrency token (e.g., Version
) in update requests and use selective updates (IsModified
) instead of Update()
.
rowversion
. What now? Use a byte[]
[Timestamp]
column (EF will handle it as a token). On SQL Server, map to rowversion
for true server‑generated values.
SaveChanges
is transactional per call. Use explicit transactions only for multi‑step workflows that must succeed/fail together.
Conclusion: You now control your data, not the other way around
Adding, updating, and deleting in EF Core is simple – when you respect tracking, concurrency, and set‑based operations. You’ve seen how to perform safe partial updates, fast bulk changes, and clean soft deletes. Try the patterns here in your project today, and you’ll avoid the classic CRUD footguns that haunt many teams.
Which update pitfall bit you recently – overwriting fields, duplicate tracking, or concurrency conflicts? Share your story (and stack trace!) in the comments.