BrandGhost
EF Core CRUD Operations in C#: Create, Read, Update, Delete

EF Core CRUD Operations in C#: Create, Read, Update, Delete

EF Core CRUD Operations in C#: Create, Read, Update, Delete

If you're building .NET applications that interact with a database, understanding ef core crud operations is non-negotiable. Entity Framework Core is the go-to ORM for .NET developers, and mastering Create, Read, Update, and Delete operations will make you significantly more productive. This guide covers everything from basic entity operations to bulk updates using the latest APIs available in .NET 10 and EF Core 10. Bulk operations like ExecuteUpdateAsync have been available since EF Core 7 -- this guide applies the full API suite as it stands in EF Core 10.

Let's dig in.


Setting Up the DbContext

Before any CRUD operation, you need a DbContext and at least one entity. We'll use a simple BlogPost entity as our working example throughout this guide.

using Microsoft.EntityFrameworkCore;

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public string AuthorId { get; set; } = string.Empty;
    public bool IsPublished { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset? UpdatedAt { get; set; }
}

public class BlogDbContext : DbContext
{
    public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options)
    {
    }

    public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
}

Register it in your DI container with your preferred database provider and you're ready:

// In Program.cs
builder.Services.AddDbContext<BlogDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("BlogDb")));

That's the baseline. Everything else builds on top of this setup.


CREATE: Adding Entities

Creating new records is the first operation most developers learn. EF Core makes it straightforward, but there are a few nuances worth understanding upfront.

Add and SaveChangesAsync

Here's how to add a new BlogPost:

public async Task<BlogPost> CreateBlogPostAsync(
    BlogDbContext context,
    string title,
    string content,
    string authorId)
{
    var post = new BlogPost
    {
        Title = title,
        Content = content,
        AuthorId = authorId,
        IsPublished = false,
        CreatedAt = DateTimeOffset.UtcNow,
    };

    context.BlogPosts.Add(post);
    await context.SaveChangesAsync();

    return post;
}

A few important things happening here:

  • Add marks the entity as Added in EF Core's change tracker. Nothing hits the database yet.
  • SaveChangesAsync is what actually writes to the database. Forget this call and your data is never persisted -- silently.
  • After SaveChangesAsync completes, EF Core populates the Id property with the database-generated value. No extra query required.

Note: Use the synchronous Add() and AddRange() for standard entity insertion. AddAsync() exists only for providers that use asynchronous value generators (e.g., SQL Server HiLo sequences). For identity/autoincrement columns -- which is the vast majority of cases -- the synchronous Add() is the recommended method. Only SaveChangesAsync() needs to be async.

Batch Inserts with AddRange

If you're inserting multiple records, use AddRange instead of calling Add in a loop. EF Core can batch these into a far more efficient SQL operation:

public async Task CreateMultiplePostsAsync(
    BlogDbContext context,
    IEnumerable<(string Title, string Content, string AuthorId)> posts)
{
    var entities = posts.Select(p => new BlogPost
    {
        Title = p.Title,
        Content = p.Content,
        AuthorId = p.AuthorId,
        IsPublished = false,
        CreatedAt = DateTimeOffset.UtcNow,
    }).ToList();

    context.BlogPosts.AddRange(entities);
    await context.SaveChangesAsync();
}

One SaveChangesAsync call handles all of them. That's the correct pattern -- batch your operations, don't loop with individual saves.


READ: Querying Entities

Reading data is where EF Core shines -- and where performance mistakes most often live. There are several approaches depending on what you need.

FindAsync: Primary Key Lookup

When you know the primary key, FindAsync is your best option. It checks the change tracker first before hitting the database, which can eliminate a round trip entirely:

var post = await context.BlogPosts.FindAsync(postId);

if (post is null)
{
    // Not found -- handle appropriately
    return null;
}

Simple, efficient, and change-tracker-aware. Use it whenever you're looking up by primary key.

FirstOrDefaultAsync and Where

For more complex queries, combine Where with FirstOrDefaultAsync or ToListAsync. This is where LINQ becomes your query language:

// Single entity matching a condition
var latestPost = await context.BlogPosts
    .Where(bp => bp.AuthorId == authorId && bp.IsPublished)
    .OrderByDescending(bp => bp.CreatedAt)
    .FirstOrDefaultAsync();

// Multiple entities
var publishedPosts = await context.BlogPosts
    .Where(bp => bp.IsPublished)
    .OrderByDescending(bp => bp.CreatedAt)
    .ToListAsync();

These queries translate directly to SQL. EF Core generates the WHERE clause, ORDER BY, and LIMIT accordingly. One thing many developers miss: LINQ queries in EF Core are deferred -- they don't execute until you enumerate them with ToListAsync, FirstOrDefaultAsync, or similar terminal methods. If you want to understand exactly when your queries execute and why that matters, the LINQ Deferred Execution in C# guide covers this in depth.

AsNoTracking for Read-Only Queries

By default, EF Core tracks every entity you load. It keeps a snapshot in memory to detect changes later. For read-only scenarios -- building API responses, rendering lists, generating reports -- that overhead is wasted.

Use AsNoTracking() to skip it:

var posts = await context.BlogPosts
    .AsNoTracking()
    .Where(bp => bp.IsPublished)
    .OrderByDescending(bp => bp.CreatedAt)
    .Take(10)
    .ToListAsync();

This is a free performance win. For genuinely read-only queries -- API responses, reports, list views -- AsNoTracking() is almost always the right choice. Reserve tracked queries for the load-modify-save pattern. The more entities you load, the more memory and CPU you save.

For deeper coverage of filtering techniques -- Where, Any, All, Contains, and more -- the LINQ Filtering in C# guide is worth bookmarking.


UPDATE: Modifying Entities

Updates come in two flavors: tracked entity updates (load, modify, save) and untracked updates. EF Core 10 also provides ExecuteUpdateAsync for bulk updates that bypass entity loading entirely.

Tracked Entity Update

This is the most common pattern. Load the entity, modify it, call SaveChangesAsync:

public async Task<bool> PublishBlogPostAsync(BlogDbContext context, int postId)
{
    var post = await context.BlogPosts.FindAsync(postId);

    if (post is null)
    {
        return false;
    }

    post.IsPublished = true;
    post.UpdatedAt = DateTimeOffset.UtcNow;

    await context.SaveChangesAsync();
    return true;
}

Because the entity is tracked, EF Core knows exactly what changed. It generates an efficient UPDATE statement targeting only the modified columns -- not the entire row.

Untracked Update with context.Update()

If you have a detached entity (deserialized from an API request, for example), use context.Update():

public async Task UpdateBlogPostAsync(BlogDbContext context, BlogPost updatedPost)
{
    updatedPost.UpdatedAt = DateTimeOffset.UtcNow;
    context.Update(updatedPost);
    await context.SaveChangesAsync();
}

Be careful here. context.Update() marks every column as modified, even ones that didn't change. It will overwrite the entire row. The tracked approach is safer for partial updates -- use context.Update() only when you have the full entity state and intend to replace it.

Note: This marks ALL properties as modified, so EF Core generates an UPDATE with every column in the SET clause -- including columns whose values haven't changed. If you only have a partial entity (e.g., deserialized from an API with missing fields), those fields will be written with whatever C# defaults your object has, potentially overwriting real data with zero-values or empty strings.

Bulk Update with ExecuteUpdateAsync

Sometimes you need to update thousands of records based on a condition without loading any of them into memory. That's exactly what ExecuteUpdateAsync is for:

// Publish all unpublished posts by a specific author -- one SQL UPDATE statement
var updatedCount = await context.BlogPosts
    .Where(bp => bp.AuthorId == authorId && !bp.IsPublished)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(bp => bp.IsPublished, true)
        .SetProperty(bp => bp.UpdatedAt, DateTimeOffset.UtcNow));

This generates a single UPDATE ... WHERE SQL statement. No entities loaded into memory, no change tracking overhead, no multiple round trips. For large datasets this is a significant performance improvement over the load-then-modify pattern.


DELETE: Removing Entities

Deletes follow a similar pattern to updates. You can delete single entities, ranges, or use the bulk API for large conditional deletes.

Remove and SaveChangesAsync

public async Task<bool> DeleteBlogPostAsync(BlogDbContext context, int postId)
{
    var post = await context.BlogPosts.FindAsync(postId);

    if (post is null)
    {
        return false;
    }

    context.BlogPosts.Remove(post);
    await context.SaveChangesAsync();
    return true;
}

You need to load the entity first (or create a stub with the correct primary key) before calling Remove. Then SaveChangesAsync issues the DELETE statement.

RemoveRange for Multiple Entities

var cutoff = DateTimeOffset.UtcNow.AddDays(-30);

var oldDrafts = await context.BlogPosts
    .Where(bp => !bp.IsPublished && bp.CreatedAt < cutoff)
    .ToListAsync();

context.BlogPosts.RemoveRange(oldDrafts);
await context.SaveChangesAsync();

This still requires loading the entities first. That's fine for small sets. For large datasets, the bulk API is the right call.

Bulk Delete with ExecuteDeleteAsync

// Delete all old unpublished drafts -- no entities loaded into memory
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);

var deletedCount = await context.BlogPosts
    .Where(bp => !bp.IsPublished && bp.CreatedAt < cutoff)
    .ExecuteDeleteAsync();

One SQL statement, zero entities allocated on the heap. ExecuteDeleteAsync is the right tool whenever you're doing conditional bulk deletes. It returns the count of affected rows so you know exactly what happened.


Wrapping It All Together: A Service Class

In real applications, you don't call DbContext directly from controllers or handlers. You wrap the data access behind a service. Here's a practical example showing all the ef core crud operations together:

public sealed class BlogPostService
{
    private readonly IDbContextFactory<BlogDbContext> _contextFactory;

    public BlogPostService(IDbContextFactory<BlogDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task<BlogPost> CreateAsync(string title, string content, string authorId)
    {
        await using var context = await _contextFactory.CreateDbContextAsync();

        var post = new BlogPost
        {
            Title = title,
            Content = content,
            AuthorId = authorId,
            IsPublished = false,
            CreatedAt = DateTimeOffset.UtcNow,
        };

        context.BlogPosts.Add(post);
        await context.SaveChangesAsync();
        return post;
    }

    public async Task<BlogPost?> GetByIdAsync(int id)
    {
        await using var context = await _contextFactory.CreateDbContextAsync();
        return await context.BlogPosts
            .AsNoTracking()
            .FirstOrDefaultAsync(bp => bp.Id == id);
    }

    public async Task<IReadOnlyList<BlogPost>> GetPublishedAsync()
    {
        await using var context = await _contextFactory.CreateDbContextAsync();
        return await context.BlogPosts
            .AsNoTracking()
            .Where(bp => bp.IsPublished)
            .OrderByDescending(bp => bp.CreatedAt)
            .ToListAsync();
    }

    public async Task<bool> PublishAsync(int id)
    {
        await using var context = await _contextFactory.CreateDbContextAsync();

        var count = await context.BlogPosts
            .Where(bp => bp.Id == id)
            .ExecuteUpdateAsync(s => s
                .SetProperty(bp => bp.IsPublished, true)
                .SetProperty(bp => bp.UpdatedAt, DateTimeOffset.UtcNow));

        return count > 0;
    }

    public async Task<bool> DeleteAsync(int id)
    {
        await using var context = await _contextFactory.CreateDbContextAsync();

        var count = await context.BlogPosts
            .Where(bp => bp.Id == id)
            .ExecuteDeleteAsync();

        return count > 0;
    }
}

Notice the use of IDbContextFactory<BlogDbContext> rather than injecting BlogDbContext directly. This is the correct pattern for Blazor Server apps and background services where the request lifetime doesn't map cleanly to a DbContext lifetime. Each method creates and disposes its own short-lived context.

If you're thinking about how to structure this service as part of a larger data access layer, the Repository Pattern via Chain of Responsibility guide shows interesting ways to compose behavior around data access. And for hiding complexity behind a clean interface, the Facade Design Pattern guide is worth a read.


Async Patterns: Why They Matter

Every method above uses async/await. That's not just convention -- it's critical for web application performance.

Database calls are I/O-bound operations. When you make a synchronous database call, the thread blocks while waiting for the database to respond. In ASP.NET Core, that blocked thread cannot handle other incoming requests.

Async database calls free the thread during the wait. The runtime can use it for other work. Under load, the difference between ToList() and ToListAsync() can mean the difference between 100 concurrent users and 10,000.

The rule is simple: async all the way. Never call async methods synchronously with .Result or .GetAwaiter().GetResult(). That completely negates the benefit and can cause deadlocks -- particularly in environments that have a synchronization context.


Change Tracking Explained

EF Core's change tracker is one of its most powerful features -- and one of the most frequently misunderstood.

When you load an entity without AsNoTracking, EF Core stores a snapshot of its original property values. When you call SaveChangesAsync, it runs change detection by comparing current values to that snapshot. Only the properties that changed end up in the UPDATE statement. If nothing changed, EF Core skips the database call entirely.

Entities can be in one of five states:

State Meaning
Added New entity -- will be INSERTed
Modified Existing entity with changes -- will be UPDATEd
Deleted Marked for removal -- will be DELETEd
Unchanged Loaded but not modified -- no SQL generated
Detached Not tracked by the context

Detached entities are the source of a lot of confusion. If you load an entity with one DbContext instance and try to update it with a different instance, EF Core will either throw an exception or produce incorrect behavior. Always work with a single context per unit of work.


Common Mistakes to Avoid

These mistakes show up constantly in code reviews. Learning to spot them early saves a lot of debugging time.

Forgetting SaveChangesAsync

This is the number one mistake. Add, AddRange, Update, Remove, and RemoveRange only modify the change tracker in memory. Without SaveChangesAsync, nothing reaches the database. Your code won't throw an error -- it just silently fails to persist your data.

Using Multiple DbContext Instances Per Unit of Work

Creating two contexts for a single operation is asking for trouble. If you load an entity on one context and try to save it through another, you'll get exceptions like "An entity with the same key value is already being tracked." Keep your units of work contained to a single DbContext instance.

Tracking Entities You Don't Need to Modify

Loading 500 entities with tracking enabled when you only need to display them in a list wastes memory and CPU cycles. Always apply AsNoTracking() for read-only queries. The larger the dataset, the more significant the overhead.

Calling SaveChangesAsync in a Loop

This is a performance killer that's surprisingly easy to write:

// ❌ Don't do this -- one database round trip per entity
foreach (var post in posts)
{
    context.BlogPosts.Add(post);
    await context.SaveChangesAsync();
}

// ✅ Do this instead -- one round trip for all entities
context.BlogPosts.AddRange(posts);
await context.SaveChangesAsync();

Batch your operations. Call SaveChangesAsync once per unit of work, not once per entity.

Overlooking Query Logging

If you're unsure what SQL EF Core is generating, turn on query logging. EF Core can log every SQL statement it executes, which is invaluable for debugging slow queries or catching N+1 issues. See the Logging in .NET Complete Guide for how to configure Microsoft.Extensions.Logging properly in your application.


FAQ

What is EF Core CRUD?

Ef core crud refers to the four fundamental database operations -- Create, Read, Update, and Delete -- performed through Entity Framework Core. EF Core maps these operations to SQL INSERT, SELECT, UPDATE, and DELETE statements, letting you work with .NET objects instead of writing raw SQL. It handles connection management, parameterization, and SQL generation automatically.

Do I Always Need to Call SaveChangesAsync?

Yes. Every time. Add, AddRange, Update, Remove, and RemoveRange only modify EF Core's internal change tracker. They don't touch the database. SaveChangesAsync (or SaveChanges) is the method that actually commits pending changes as SQL statements within a transaction. If you skip it, your data is not persisted -- and you won't get an error to tell you so.

What's the Difference Between FindAsync and FirstOrDefaultAsync in EF Core?

FindAsync looks up an entity by primary key and checks the DbContext's change tracker first. If the entity is already loaded in the current context, it returns it without a database query -- a useful optimization. FirstOrDefaultAsync always issues a database query and supports arbitrary Where conditions, not just primary key lookups. Use FindAsync when you have a primary key; use FirstOrDefaultAsync for everything else.

When Should I Use AsNoTracking?

Use AsNoTracking() whenever you load entities you won't modify in the same unit of work. The most common cases are read-heavy scenarios: API GET endpoints, data exports, report generation, and rendering lists in a UI. Tracking has a memory and CPU cost because EF Core stores original-value snapshots. If you're never going to call SaveChangesAsync after loading, skip tracking entirely. You can also apply it globally via context.ChangeTracker.QueryTrackingBehavior if read-only is the norm for a given context.

What Are ExecuteUpdateAsync and ExecuteDeleteAsync?

These are bulk operation APIs available in EF Core 7 and later, including EF Core 10. They translate to a single UPDATE ... WHERE or DELETE ... WHERE SQL statement, bypassing change tracking entirely. No entities are loaded into memory. They return the count of affected rows and are the right choice when you need to update or delete many rows based on a condition without needing the entity data on the application side.

How Does EF Core Detect Changes in Tracked Entities?

When you load an entity with tracking enabled, EF Core stores a snapshot of its original property values. When SaveChangesAsync is called, EF Core's change detector compares current property values against those snapshots. Any property that differs gets included in the UPDATE statement. If nothing changed, no SQL is issued at all. You can also trigger detection manually via context.ChangeTracker.DetectChanges(), though this is rarely needed in typical usage.

Can I Mix ef core crud Operations in One SaveChangesAsync Call?

Absolutely. You can add new entities, modify existing ones, and mark others for deletion all within a single DbContext instance, then call SaveChangesAsync once. EF Core issues all necessary INSERT, UPDATE, and DELETE statements in a single database transaction. This is exactly how transactional units of work are supposed to function -- and it's far more efficient than calling SaveChangesAsync after each individual operation.


Wrapping Up

Ef core crud operations are the foundation of every data-driven .NET application. The fundamentals matter here: always call SaveChangesAsync, use AsNoTracking for read-only queries, batch your inserts and deletes, and reach for ExecuteUpdateAsync/ExecuteDeleteAsync when you need bulk operations without the memory overhead.

The service class pattern with IDbContextFactory is particularly valuable in real applications where controlling DbContext lifetime gets tricky. And change tracking -- once you truly understand it -- stops being a source of mystery bugs and starts being a powerful tool.

If you want to go deeper on query composition, LINQ Projection in C# covers Select and SelectMany -- two operations you'll use constantly as your queries grow more complex. And for a thorough grounding in LINQ itself (which underpins everything in EF Core's query API), the LINQ in C# Complete Guide is a great place to start. When dependency injection questions come up -- and they always do -- How DI Containers Use Reflection Internally gives you a great look at what's happening under the hood when you register your DbContext.

Entity Framework Core in Blazor - Dev Leader Weekly 30

Welcome to another issue of Dev Leader Weekly! In this issue, I dive into how we can use entity framework core in our Blazor application!

Entity Framework Core in .NET: The Complete Guide

Learn Entity Framework Core in .NET 10 -- DbContext, migrations, LINQ queries, relationships, performance tips, and when to use EF Core vs Dapper in C#.

Entity Framework Core Tutorial: Getting Started in ASP.NET Core

Step-by-step Entity Framework Core tutorial for .NET 10 -- learn DbContext setup, entities, data annotations, first migration, and SQLite or SQL Server.

An error has occurred. This application may no longer respond until reloaded. Reload