Migrations & CRUD
dotnet ef migrations add/database update, and a complete CRUD example against a DbSet.
Installing the EF Core CLI tools
Migrations are driven by the dotnet-ef global tool plus the design-time package in your project:
dotnet tool install --global dotnet-ef
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
Creating and applying a migration
Starting from the Product entity and AppDbContext from the previous page:
dotnet ef migrations add InitialCreate
This inspects your current model, compares it against the last-known snapshot (or nothing, the first time), and generates a migration — a pair of C# files under a new Migrations/ folder:
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(nullable: false),
Price = table.Column<decimal>(nullable: false),
StockQuantity = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Products");
}
}
Up is what runs when the migration is applied; Down is the reverse, used when rolling back. Always review a generated migration before applying it — EF Core is usually right, but a renamed property can be misread as a drop-and-recreate (destroying data) instead of a rename.
Apply it to the actual database:
dotnet ef database update
Every later model change follows the same two-step loop: change your entity classes → dotnet ef migrations add <DescriptiveName> → review the generated file → dotnet ef database update.
Roll back to an earlier migration (or all the way to none) if needed:
dotnet ef database update PreviousMigrationName
dotnet ef database update 0 # undo every migration
CRUD: Create
app.MapPost("/products", async (Product input, AppDbContext db) =>
{
db.Products.Add(input); // starts tracking input as "Added"
await db.SaveChangesAsync(); // generates and runs the INSERT
return Results.Created($"/products/{input.Id}", input);
});
Add doesn't touch the database by itself — it only marks the entity for insertion in the change tracker. Nothing happens until SaveChangesAsync() actually executes the generated SQL.
CRUD: Read
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.ToListAsync());
app.MapGet("/products/{id:int}", async (int id, AppDbContext db) =>
{
var product = await db.Products.FindAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});
FindAsync first checks the context's in-memory change tracker before hitting the database — if that exact entity was already loaded in this DbContext instance, no query runs at all.
CRUD: Update
app.MapPut("/products/{id:int}", async (int id, Product input, AppDbContext db) =>
{
var product = await db.Products.FindAsync(id);
if (product is null) return Results.NotFound();
product.Name = input.Name;
product.Price = input.Price;
product.StockQuantity = input.StockQuantity;
await db.SaveChangesAsync(); // generates and runs the UPDATE
return Results.NoContent();
});
Because product was loaded through this same db instance, EF Core is already tracking it — simply changing its properties is enough; there's no separate "mark as modified" call needed (unlike attaching a detached entity from outside the current context, which does require db.Entry(product).State = EntityState.Modified).
CRUD: Delete
app.MapDelete("/products/{id:int}", async (int id, AppDbContext db) =>
{
var product = await db.Products.FindAsync(id);
if (product is null) return Results.NotFound();
db.Products.Remove(product);
await db.SaveChangesAsync(); // generates and runs the DELETE
return Results.NoContent();
});
What SaveChanges actually does
A single SaveChangesAsync() call wraps every pending Added/Modified/Deleted entity from the change tracker into one database transaction — either every change commits, or (on any failure) none of them do. This is why the typical pattern is "make several related changes, then call SaveChangesAsync() once," rather than saving after every single property change.
Common mistakes
- Calling
SaveChanges()(orSaveChangesAsync()) after every tiny change instead of batching related changes into one call — each call is a full round trip and its own transaction. - Not reviewing a generated migration before applying it — a property rename can be generated as a destructive drop-and-recreate instead of a rename, silently losing data in that column.
- Forgetting
awaitbeforeSaveChangesAsync()— the method returns aTaskimmediately; withoutawait, your code moves on before the save (or its exceptions) has actually happened.
Interview questions
Q: What does dotnet ef migrations add actually generate?
A new C# migration file with Up() and Down() methods describing exactly how to transform the schema forward and backward, generated by diffing your current entity model against the last applied migration's snapshot. It doesn't touch the actual database — that only happens when you run dotnet ef database update.
Q: Does calling Add() on a DbSet immediately insert a row?
No. Add() only changes that entity's tracked state to Added in the context's in-memory change tracker. The actual INSERT SQL is only generated and executed when SaveChanges()/SaveChangesAsync() is called.
Q: What guarantees does a single SaveChanges() call provide?
It wraps every pending tracked change (inserts, updates, deletes) into one database transaction — they all commit together, or none of them do if any part fails, keeping the database consistent even when a single SaveChanges() call represents several logically related changes.