BrandGhost
EF Core Migrations in .NET: Managing Your Database Schema

EF Core Migrations in .NET: Managing Your Database Schema

EF Core Migrations in .NET: Managing Your Database Schema

If you've ever manually run SQL scripts to keep your development and production databases in sync, you already know how quickly that falls apart. EF Core migrations solve this problem by letting you track database schema changes directly in code -- versioned, repeatable, and automatable. In this guide, we'll walk through everything you need to know: creating and applying migrations, seeding data the modern way with UseSeeding, reverting changes, and running migrations safely in CI/CD pipelines. All examples target .NET 10 and EF Core 10.


What Are EF Core Migrations and Why Do They Matter?

A migration is a code-first snapshot of a change to your data model. Every time you add a property, rename a table, or drop a column, EF Core can generate a migration that captures exactly what changed and how to reverse it.

This matters for a few reasons:

  • Repeatability -- the same migration runs identically on every developer machine, every test environment, and production.
  • History -- your git history becomes your schema history. You can see who changed what and when.
  • Reversibility -- every migration has an Up method (apply) and a Down method (revert). Mistakes are recoverable.

For teams using modular monolith architecture in C#, migrations are especially powerful -- each module can own its own DbContext and its own migration history, keeping schema changes isolated.


Prerequisites: Installing the dotnet-ef Tool

Before you can create or apply migrations from the command line, you need the dotnet-ef global tool installed.

dotnet tool install --global dotnet-ef

If you've installed it before but need to update:

dotnet tool update --global dotnet-ef

You'll also need the Microsoft.EntityFrameworkCore.Design package in your project. This package provides the design-time services that power dotnet ef commands:

dotnet add package Microsoft.EntityFrameworkCore.Design

Verify everything is set up correctly:

dotnet ef --version

A common pitfall: if dotnet ef isn't found after installing, your PATH may not include the global tools directory. On Windows, check %USERPROFILE%.dotnet ools. On macOS/Linux, check ~/.dotnet/tools. Add it to your PATH if needed.


Creating Your First Migration

Once your DbContext is set up with at least one entity, you can generate your first migration:

dotnet ef migrations add InitialCreate

EF Core will inspect your model and generate three files inside a Migrations folder:

  1. <timestamp>_InitialCreate.cs -- the migration class with Up() and Down() methods
  2. <timestamp>_InitialCreate.Designer.cs -- metadata snapshot used internally
  3. <YourDbContext>ModelSnapshot.cs -- the current state of the entire model

Here's what a generated migration class looks like for a simple Post entity:

using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace MyApp.Migrations
{
    /// <inheritdoc />
    public partial class InitialCreate : Migration
    {
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Posts",
                columns: table => new
                {
                    Id = table.Column<int>(type: "INTEGER", nullable: false)
                        .Annotation("Sqlite:Autoincrement", true),
                    Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
                    Content = table.Column<string>(type: "TEXT", nullable: false),
                    PublishedAt = table.Column<DateTimeOffset>(
                        type: "TEXT", nullable: true),
                    CreatedAt = table.Column<DateTimeOffset>(
                        type: "TEXT", nullable: false)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Posts", x => x.Id);
                });
        }

        /// <inheritdoc />
        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropTable(name: "Posts");
        }
    }
}

The Up method runs when you apply the migration. The Down method runs when you revert it. EF Core generates both automatically -- though for complex data transformations you may need to fill in the Down method manually.

You can name migrations anything that makes sense to your team: AddUserProfileTable, RenameEmailColumn, AddIndexOnSlug. Clear, descriptive names make your migration history readable at a glance.


Applying Migrations

Via the CLI

The simplest way to apply all pending migrations is:

dotnet ef database update

This applies every migration that hasn't been applied yet, in order. EF Core knows which ones to run because it tracks applied migrations in a special table called __EFMigrationsHistory (more on that below).

To apply migrations up to a specific one (and not beyond):

dotnet ef database update AddUserProfileTable

Via Code at Startup with MigrateAsync

For applications where you want migrations to run automatically when the app starts -- handy for containerized deployments or development environments -- use MigrateAsync() in Program.cs:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

// Apply pending migrations on startup
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

app.MapGet("/", () => "Hello World!");

await app.RunAsync();

This is idempotent -- if no migrations are pending, MigrateAsync() does nothing. Use it carefully in production though: applying migrations at startup means downtime risks if a migration is long-running. For production, migration bundles (covered later) give you more control.


The __EFMigrationsHistory Table

Every time you apply a migration, EF Core inserts a row into a table called __EFMigrationsHistory. Each row records the migration ID (a combination of timestamp and name) and the EF Core version that applied it.

SELECT * FROM __EFMigrationsHistory;
-- MigrationId                              | ProductVersion
-- 20250115120000_InitialCreate             | 10.0.0
-- 20250210093000_AddUserProfileTable       | 10.0.0

When you run dotnet ef database update, EF Core queries this table to find which migrations haven't been applied yet and runs only those. This is what makes migrations idempotent -- safe to run multiple times without side effects.

You should never manually delete rows from this table unless you know exactly what you're doing. Doing so will cause EF Core to attempt to re-run already-applied migrations, which typically fails with errors about existing tables or columns.


Squashing Migrations: Creating a Clean Baseline

Over time, migration history can accumulate dozens or hundreds of files. This is fine for green-field projects, but once you've shipped to production, old migrations become clutter. Squashing (also called consolidating) collapses your migration history into a single clean baseline.

The general approach:

  1. Delete all existing migration files from the Migrations folder.
  2. Run dotnet ef migrations add InitialSchema to regenerate a single migration from the current model snapshot.
  3. Manually mark that migration as already applied in __EFMigrationsHistory on any databases that are already up-to-date:
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
VALUES ('20260101000000_InitialSchema', '10.0.0');

This tells EF Core "this database is already at this state -- don't run the migration again."

Do this carefully. Squashing is safe when all deployed environments are at the same migration version. Never squash migrations that some environments haven't applied yet -- you'll create a state mismatch that's painful to untangle.


Data Seeding in EF Core 9+: UseSeeding and UseAsyncSeeding

EF Core has supported HasData for seed data for years. But HasData has a fundamental limitation -- all seed data must be known at model build time, using hard-coded primary key values. That's fine for static reference data like lookup tables, but it breaks down for anything dynamic.

In EF Core 9+, the recommended approach for seeding dynamic or environment-specific data is UseSeeding and UseAsyncSeeding. These methods are called after migrations are applied, giving you access to the fully configured DbContext.

Here's how to wire them up using DbContextOptionsBuilder:

public class AppDbContext : DbContext
{
    public DbSet<Post> Posts => Set<Post>();
    public DbSet<Tag> Tags => Set<Tag>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Post>(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.Title).HasMaxLength(200).IsRequired();
        });
    }
}

// In Program.cs, configure seeding via options:
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"));

    options.UseSeeding((context, _) =>
    {
        // Runs synchronously after migration -- use for simple seed data
        if (!context.Set<Tag>().Any())
        {
            context.Set<Tag>().AddRange(
                new Tag { Name = "csharp" },
                new Tag { Name = "dotnet" },
                new Tag { Name = "efcore" }
            );
            context.SaveChanges();
        }
    });

    options.UseAsyncSeeding(async (context, _, cancellationToken) =>
    {
        // Prefer this for anything awaitable
        if (!await context.Set<Post>().AnyAsync(cancellationToken))
        {
            context.Set<Post>().Add(new Post
            {
                Title = "Welcome to my blog",
                Content = "This is the first post.",
                CreatedAt = DateTimeOffset.UtcNow
            });
            await context.SaveChangesAsync(cancellationToken);
        }
    });
});

Key differences from HasData:

  • No hard-coded primary keys required -- EF Core handles identity generation normally.
  • Can call external services or read config -- since seeding runs at app startup with a live DbContext, you have access to the full DI container if needed.
  • Idempotent by convention -- the if (!context.Set<T>().Any()) guard prevents duplicate inserts on every startup.

This pairs naturally with the repository pattern -- your seeding logic can even use repository abstractions instead of calling DbContext directly.


Reverting Migrations

Rolling Back to a Previous State

To revert the database to a previous migration state, pass the target migration name to database update:

# Revert to the state after AddUserProfileTable was applied
dotnet ef database update AddUserProfileTable

EF Core runs the Down method for each migration that came after the target, in reverse order.

To revert all migrations (empty database):

dotnet ef database update 0

Removing the Last Migration

If you've created a migration but haven't applied it yet, you can remove it cleanly:

dotnet ef migrations remove

This deletes the migration file and updates the model snapshot. You can only remove the most recent migration -- there's no way to remove a migration from the middle of the history without manually editing files.

If you've already applied the migration to any database, remove it first:

dotnet ef database update <PreviousMigrationName>
dotnet ef migrations remove

CI/CD with EF Core Migrations

Running migrations in a CI/CD pipeline is one of the highest-leverage things you can do for deployment reliability. There are two main approaches.

Approach 1: dotnet ef database update in the Pipeline

The straightforward approach -- run dotnet ef database update as a deploy step:

# In your CI/CD pipeline (GitHub Actions, Azure Pipelines, etc.)
dotnet ef database update 
  --project src/MyApp.Data 
  --startup-project src/MyApp.Web 
  --connection "$DATABASE_CONNECTION_STRING"

This works well for simple setups. The downside is that the dotnet-ef tool must be installed on the CI runner, and you need a direct database connection string available at deploy time.

For DBA-managed production deployments, the --idempotent flag generates a SQL script that checks __EFMigrationsHistory before applying each migration -- safe to run on a database at any migration state:

dotnet ef migrations script --idempotent --output migrations.sql

Approach 2: Migration Bundles

Migration bundles are a self-contained executable that applies your migrations. You build it once, ship it as an artifact, and run it at deploy time -- no need for the dotnet-ef tool or the EF Core design packages in the deployment environment.

# Build the bundle (produces an efbundle executable)
dotnet ef migrations bundle 
  --project src/MyApp.Data 
  --startup-project src/MyApp.Web 
  --output ./artifacts/efbundle 
  --self-contained

# Run it at deploy time
./artifacts/efbundle --connection "$DATABASE_CONNECTION_STRING"

Bundles are ideal for containerized deployments, air-gapped environments, and production pipelines where you want to separate "build the migration artifact" from "apply it." The bundle includes all pending migrations baked in at build time -- a clean, auditable artifact.

Logging is worth setting up alongside migrations for observability. The complete logging in .NET guide covers how to configure structured logging, and pairing it with Serilog gives you rich diagnostic output around migration runs. If you're running ASP.NET Core specifically, the step-by-step Serilog setup guide walks you through wiring it all up.


Common Pitfalls to Avoid

1. Missing the dotnet-ef Tool

The most common beginner error. If you see No executable found matching command "dotnet-ef", the tool isn't installed or isn't on your PATH. Run dotnet tool install --global dotnet-ef and verify with dotnet ef --version.

2. Model Snapshot Conflicts

In a team environment, two developers can independently create migrations that both modify the model snapshot file. Git will flag this as a merge conflict. The fix: resolve the snapshot conflict carefully, making sure the final snapshot reflects the combined model state. Then verify by running dotnet ef migrations add -- if EF Core detects no pending model changes, the snapshot is correct.

3. Applying Migrations Directly in Production Without a Plan

MigrateAsync() at startup is convenient, but in production it can mean:

  • Long-running migrations blocking startup
  • Failed migrations crashing the app on launch
  • No rollback window if something goes wrong

For production systems, prefer migration bundles run as a pre-deployment step -- separate from the application startup. This gives you a chance to validate the migration, take a backup, and roll back if needed.

4. Forgetting the Microsoft.EntityFrameworkCore.Design Package

dotnet ef migrations add will fail with a cryptic error if the Design package isn't referenced in your startup project. Always double-check this when setting up a new project or moving DbContext to a separate class library.

5. Circular Dependencies Between Modules

If you're using multiple DbContext classes (common in monolith architecture or modular designs), keep their migrations entirely separate. Don't let one module's migrations reference entities from another. Schema coupling between modules is the data-layer equivalent of tight code coupling -- and it's just as painful to untangle.


FAQ

What is an EF Core migration?

An EF Core migration is a C# class that captures a schema change to your database model. It contains an Up method (applies the change) and a Down method (reverts it). Migrations are generated automatically by the dotnet ef migrations add command and are stored as versioned files in your project.

Do I need to run dotnet ef database update every time I change my model?

Yes -- whenever you modify your entity classes or DbContext configuration, you need to create a new migration with dotnet ef migrations add <Name> and then apply it with dotnet ef database update. Skipping the migration means your database schema will be out of sync with your model, causing runtime errors.

What is the __EFMigrationsHistory table?

It's a table EF Core creates in your database to track which migrations have been applied. Each row contains a migration ID and the EF Core version. When you run dotnet ef database update, EF Core queries this table to determine which migrations are pending and runs only those.

Is it safe to use MigrateAsync() in production?

It can be, but it carries risks. If a migration is long-running or fails partway through, your app startup fails or leaves the database in a partial state. For production workloads, migration bundles give you more control -- run the migration as a pre-deployment step, validate it succeeded, then deploy the app.

What is the difference between HasData and UseSeeding?

HasData embeds seed data in the migration itself. The data must be known at model-build time and requires hard-coded primary key values. It's good for static reference data. UseSeeding and UseAsyncSeeding (the EF Core 9+ recommended approach) run after migrations are applied at app startup, with access to the live DbContext. They support dynamic data, no hard-coded keys, and conditional logic like "only seed if no rows exist."

How do I create a migration bundle for CI/CD?

Run dotnet ef migrations bundle from your solution folder, specifying the data project and startup project. Use --self-contained to produce a binary that doesn't require the .NET runtime to be installed on the deployment target. Store the bundle as a CI artifact and run it during your deploy stage with the production connection string passed as an environment variable or argument.

When should I squash my migrations?

Squash when your migration history has grown unwieldy and all deployed environments are at the same migration version. Squashing collapses everything into a single baseline migration, reducing clutter and improving dotnet ef command performance. Never squash if some environments are behind -- they won't have the individual migrations needed to catch up to the squashed baseline.


Wrapping Up

EF Core migrations are the backbone of database schema management in .NET applications. Start small -- create migrations for each model change, apply them with dotnet ef database update, and let the __EFMigrationsHistory table do the tracking. As your project matures, layer in UseAsyncSeeding for flexible data seeding, migration bundles for production-safe CI/CD, and a squashing strategy to keep your history clean.

The patterns here scale well -- from a solo side project running SQLite to a team shipping to Azure SQL with blue/green deployments. The key is treating your migrations as first-class citizens of your codebase: reviewed, tested, and deployed with the same discipline as your application code.

For a deeper look at how EF Core fits into larger architectural patterns, the dependency injection internals guide explains how EF Core's design-time tooling uses reflection to discover your DbContext -- which is directly relevant to understanding why the Design package is required and how dotnet ef finds your context at build time.

Happy migrating.

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.

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

Master EF Core CRUD in C# with .NET 10 -- learn AddAsync, SaveChangesAsync, FindAsync, Where queries, tracked updates, and bulk ExecuteUpdate/ExecuteDelete.

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