Read, create, update and delete data with EF Core, understand the change tracker, use AsNoTracking for reads, and avoid the N plus 1 and over fetching traps.

Bishal Biswas
Self Learner
The change tracker
Before any CRUD makes sense, understand the one idea behind all of it.
When EF Core loads an entity, it keeps a copy of the original values. When you call SaveChanges, it compares the current values against that copy and generates SQL for whatever changed.
Book book = await db.Books.FindAsync(1); // tracked, original values remembered
book.Price = 29.99m; // just a C# assignment so far
await db.SaveChangesAsync(); // UPDATE Books SET Price = 29.99 WHERE Id = 1
There is no db.Update(book) in that code. You changed a property and saved. The tracker worked out the rest.
Every state an entity can be in: Added, Unchanged, Modified, Deleted, Detached. SaveChanges turns them into INSERT, nothing, UPDATE, DELETE and nothing.
Reading
Find by primary key:
Book? book = await db.Books.FindAsync(id);
FindAsync checks the change tracker first, so if the entity is already loaded it returns it without touching the database. That makes it the right choice for a lookup by id.
Query with LINQ:
// One item, or null
Book? book = await db.Books.FirstOrDefaultAsync(b => b.Isbn == isbn);
// Many
List<Book> books = await db.Books
.Where(b => b.Year > 2000 && b.Price < 50)
.OrderBy(b => b.Title)
.ToListAsync();
// Just the numbers
int count = await db.Books.CountAsync(b => b.Year > 2000);
bool any = await db.Books.AnyAsync(b => b.AuthorId == 3);
decimal max = await db.Books.MaxAsync(b => b.Price);
Two things worth knowing about the single item methods:
FirstOrDefaultAsyncreturns null when nothing matches.FirstAsyncthrows.SingleOrDefaultAsyncthrows if more than one matches, which is a useful assertion when you expect uniqueness. It costs an extra row read, so useFirstwhen you genuinely just want the first.
Queries are lazy until you materialise them
Building a query runs nothing:
IQueryable<Book> query = db.Books.Where(b => b.Year > 2000); // no SQL yet
if (!string.IsNullOrEmpty(title))
query = query.Where(b => b.Title.Contains(title)); // still no SQL
if (sortByPrice)
query = query.OrderBy(b => b.Price); // still no SQL
List<Book> results = await query.ToListAsync(); // now one SQL query runs
This composability is what makes filtering and paging clean. The SQL is built once, with every condition in it.
The methods that trigger execution: ToListAsync, FirstOrDefaultAsync, SingleAsync, CountAsync, AnyAsync, SumAsync, MaxAsync and their siblings, plus a foreach.
AsNoTracking for reads
Tracking exists so SaveChanges can detect changes. If you are only reading, it is pure overhead: EF Core builds a snapshot of every entity it returns.
List<Book> books = await db.Books.AsNoTracking().ToListAsync();
Faster and less memory. Use it on every read only query, which in an API is most of them.
You can even make it the default and opt in to tracking where you need it:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(cs)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
Better still, project to a DTO. A projection is never tracked, because the result is not an entity:
var items = await db.Books
.Select(b => new BookListItem(b.Id, b.Title, b.Price))
.ToListAsync();
Creating
var book = new Book { Title = "Refactoring", Year = 1999, AuthorId = 2 };
db.Books.Add(book);
await db.SaveChangesAsync();
// book.Id is now populated by the database
Several at once:
db.Books.AddRange(book1, book2, book3);
await db.SaveChangesAsync(); // one round trip, batched
Call SaveChanges once after adding everything. Calling it inside a loop turns one batched round trip into a hundred.
Updating
The tracked way, which is the normal way:
Book? book = await db.Books.FindAsync(id);
if (book is null) return NotFound();
book.Title = request.Title;
book.Price = request.Price;
await db.SaveChangesAsync(); // UPDATE with only the changed columns
Notice that EF Core writes only the columns you touched.
The detached way, when you already have the values and want to skip the read:
var book = new Book { Id = id, Title = request.Title, Price = request.Price };
db.Books.Update(book); // marks EVERY property as modified
await db.SaveChangesAsync();
This saves a SELECT but writes every column, including any you did not set, which will overwrite them with defaults. Only use it when your object genuinely holds the complete current state.
Bulk updates without loading
EF Core 7 added ExecuteUpdate and ExecuteDelete, which run a single SQL statement and never load anything:
// One UPDATE statement, no entities loaded, no change tracking
await db.Books
.Where(b => b.Year < 1990)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.IsArchived, true));
// One DELETE statement
await db.Books
.Where(b => b.IsDeleted && b.DeletedAt < cutoff)
.ExecuteDeleteAsync();
These are dramatically faster for bulk work. Two caveats: they run immediately rather than waiting for SaveChanges, and they bypass the change tracker, so entities already in memory will be stale.
Deleting
Book? book = await db.Books.FindAsync(id);
if (book is null) return NotFound();
db.Books.Remove(book);
await db.SaveChangesAsync();
Most production systems prefer a soft delete, which keeps the row and hides it:
book.IsDeleted = true;
book.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
Combine that with a global query filter (HasQueryFilter(b => !b.IsDeleted)) and deleted rows disappear from every query automatically, while remaining recoverable and preserving foreign keys.
The N plus 1 problem
The most common EF Core performance bug, and it is easy to write by accident:
List<Book> books = await db.Books.ToListAsync(); // 1 query
foreach (Book book in books)
{
Console.WriteLine(book.Author.Name); // 1 query EACH
}
One hundred books means one hundred and one queries. Load the related data in the same query with Include:
List<Book> books = await db.Books
.Include(b => b.Author)
.ToListAsync(); // 1 query with a JOIN
Or, better for an API, project only what you need:
var items = await db.Books
.Select(b => new BookListItem(b.Id, b.Title, b.Author.Name))
.ToListAsync(); // 1 query, 3 columns
Relationships and Include get a full topic next.
Transactions
SaveChanges is already a transaction. Everything in one call succeeds or fails together:
db.Books.Add(book);
db.Orders.Add(order);
await db.SaveChangesAsync(); // both, or neither
You only need an explicit transaction when several SaveChanges calls must be atomic:
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
try
{
db.Orders.Add(order);
await db.SaveChangesAsync();
await inventoryService.ReserveAsync(order.Id); // needs the order id
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
Always pass the cancellation token
When a client disconnects, ASP.NET Core cancels the request. Pass the token through and the database query stops too:
[HttpGet]
public async Task<ActionResult<List<BookListItem>>> GetAll(CancellationToken cancellationToken)
{
return await db.Books
.AsNoTracking()
.Select(b => new BookListItem(b.Id, b.Title, b.Price))
.ToListAsync(cancellationToken);
}
The parameter binds automatically. It costs one word per method and saves wasted database work under load.
Quick recap
- The change tracker compares current values against the originals, so changing a property and calling
SaveChangesis enough. FindAsynclooks in the tracker first, which makes it the best lookup by primary key.- Queries are lazy. Compose
WhereandOrderByfreely, and one SQL statement runs at the end. - Use
AsNoTrackingfor read only queries, or project to a DTO, which is never tracked. - Call
SaveChangesonce after adding a batch, never inside a loop. db.Update(entity)writes every column. Prefer loading and editing when you only change some fields.ExecuteUpdateAsyncandExecuteDeleteAsyncdo bulk work in one statement without loading entities.Includeor a projection prevents the N plus 1 problem. Watch for property access inside a loop.SaveChangesis already transactional. Add an explicit transaction only across multiple saves.- Accept and pass a
CancellationTokenon every async action.
// BooksController.cs - every CRUD operation, written the way you would ship it.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
private readonly AppDbContext db;
public BooksController(AppDbContext db) => this.db = db;
// READ many: projected, so it is untracked and reads only three columns.
// Note the CancellationToken: it binds automatically and stops the query
// if the client disconnects.
[HttpGet]
public async Task<ActionResult<List<BookListItem>>> GetAll(
[FromQuery] int? year, [FromQuery] string? title, CancellationToken ct)
{
IQueryable<Book> query = db.Books; // nothing has run yet
if (year is not null) query = query.Where(b => b.Year == year);
if (title is not null) query = query.Where(b => b.Title.Contains(title));
return await query
.OrderBy(b => b.Title)
.Select(b => new BookListItem(b.Id, b.Title, b.Price, b.Author.Name))
.ToListAsync(ct); // one SQL query, with a join
}
// READ one: FindAsync checks the change tracker before hitting the database
[HttpGet("{id:int}")]
public async Task<ActionResult<BookResponse>> GetById(int id, CancellationToken ct)
{
BookResponse? dto = await db.Books
.AsNoTracking() // read only, so skip the snapshot
.Where(b => b.Id == id)
.Select(b => new BookResponse(b.Id, b.Title, b.Year, b.Price, b.Author.Name))
.FirstOrDefaultAsync(ct);
return dto is null ? NotFound() : dto;
}
// CREATE: Add then one SaveChanges. The database fills in book.Id.
[HttpPost]
public async Task<ActionResult<BookResponse>> Create(CreateBookRequest request, CancellationToken ct)
{
if (!await db.Authors.AnyAsync(a => a.Id == request.AuthorId, ct))
return BadRequest(new { error = $"Author {request.AuthorId} does not exist." });
var book = new Book
{
Title = request.Title, Year = request.Year,
Price = request.Price, AuthorId = request.AuthorId,
CreatedAt = DateTime.UtcNow
};
db.Books.Add(book);
await db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(GetById), new { id = book.Id },
new BookResponse(book.Id, book.Title, book.Year, book.Price, ""));
}
// UPDATE: load, change properties, save. EF writes only the changed columns.
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateBookRequest request, CancellationToken ct)
{
Book? book = await db.Books.FindAsync(new object[] { id }, ct);
if (book is null) return NotFound();
book.Title = request.Title;
book.Year = request.Year;
book.Price = request.Price;
await db.SaveChangesAsync(ct); // UPDATE ... SET Title, Year, Price
return NoContent();
}
// DELETE, done as a soft delete. Pair with HasQueryFilter(b => !b.IsDeleted)
// so hidden rows vanish from every query automatically.
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
Book? book = await db.Books.FindAsync(new object[] { id }, ct);
if (book is null) return NotFound();
book.IsDeleted = true;
book.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return NoContent();
}
// BULK: one SQL statement, nothing loaded into memory.
// Runs immediately and bypasses the change tracker.
[HttpPost("archive-old")]
public async Task<IActionResult> ArchiveOld([FromQuery] int before, CancellationToken ct)
{
int affected = await db.Books
.Where(b => b.Year < before && !b.IsArchived)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.IsArchived, true), ct);
return Ok(new { archived = affected });
}
// An explicit transaction, needed only when several SaveChanges calls must be atomic
[HttpPost("{id:int}/reorder")]
public async Task<IActionResult> Reorder(int id, int quantity, CancellationToken ct)
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
var order = new PurchaseOrder { BookId = id, Quantity = quantity };
db.PurchaseOrders.Add(order);
await db.SaveChangesAsync(ct); // we need order.Id below
await db.Books.Where(b => b.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.LastOrderId, order.Id), ct);
await tx.CommitAsync(ct);
return Ok(new { orderId = order.Id });
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}
public record BookListItem(int Id, string Title, decimal Price, string AuthorName);
public record BookResponse(int Id, string Title, int Year, decimal Price, string AuthorName);
public record CreateBookRequest(string Title, int Year, decimal Price, int AuthorId);
public record UpdateBookRequest(string Title, int Year, decimal Price);
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready