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:

Bash
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:

Bash
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:

C#
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:

Bash
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:

Bash
dotnet ef database update PreviousMigrationName
dotnet ef database update 0    # undo every migration

CRUD: Create

C#
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

C#
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

C#
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

C#
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() (or SaveChangesAsync()) 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 await before SaveChangesAsync() — the method returns a Task immediately; without await, 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.