Entity Framework Core: Mastering Data Operations for Beginners

EF Core Add, Update, Delete – Beginner’s Guide (2025)

Learn to add, update, and delete in EF Core with real code: tracking vs disconnected, bulk ops, soft deletes, concurrency, and transactions.

.NET Development Databases·By amarozka · September 23, 2025

EF Core Add, Update, Delete – Beginner’s Guide (2025)

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 as Added. After SaveChanges, EF sets the generated Id.
  • 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 TodoListTodoItem, 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 selective IsModified 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 handle DbUpdateException 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. Log InnerException 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 entity Unchanged? Inspect ChangeTracker.

Mini cheat sheet (bookmark this)

  • Add: Add/AddRangeSaveChanges
  • Update (tracked): load → mutate → SaveChanges
  • Update (disconnected): Attach(stub) → set specific props → mark IsModified
  • Bulk: ExecuteUpdate/ExecuteDelete
  • Soft delete: IsDeleted + HasQueryFilter
  • Concurrency: Timestamp/rowversion + handle DbUpdateConcurrencyException
  • Perf: AsNoTracking for reads, project to DTOs, avoid Update() unless you truly replace all fields

FAQ: EF Core data operations for beginners

Should I always use repositories?

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.

When do I use AsNoTracking?

Use for read‑only queries or when you’ll attach a stub later to update. It reduces memory and speeds up queries.

Is 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).

How do I prevent overwrites in Web APIs?

Require a concurrency token (e.g., Version) in update requests and use selective updates (IsModified) instead of Update().

SQLite doesn’t have 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.

Do I need transactions?

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.

Leave a Reply

Your email address will not be published. Required fields are marked *