AlgoMaster Logo

Migrations

High Priority24 min readUpdated June 6, 2026

A migration is a versioned, code-reviewable description of how your database schema should change. Without migrations, you'd either edit production tables by hand (terrifying) or drop and recreate the database every time a class changes (fine for demos, useless for anything real). EF Core's migrations system tracks every schema change as a checked-in C# file, applies them in order, and remembers which ones have already been run. This lesson covers the workflow: installing the tooling, creating migrations, applying them, rolling back, generating SQL for production, seeding data, and the small set of things that go wrong if you treat migrations carelessly.

Why Migrations Exist

If you're coming to migrations for the first time, the simplest way to think about them is "git for your database schema". The C# model classes describe what the schema should look like right now. The migrations folder records how the schema got from empty to its current shape, one step at a time. Both pieces are version-controlled and travel together.

Two everyday problems push teams toward migrations:

  • Multiple environments. Local dev, CI, staging, and production all need the same schema. Hand-editing each one is how columns end up named customer_id in dev and CustomerId in prod, and how production breaks at 2 AM. Migrations let you run the exact same script everywhere.
  • Multiple developers. Two engineers add a column to Products at the same time. With migrations, the conflict shows up in the migration files at PR time, where it's easy to fix. Without migrations, the conflict shows up in production when one deploy overwrites the other's schema changes.

There's also a less obvious reason: migrations are reversible. Each one knows how to roll itself back. If a release goes wrong, you can roll the database back to the previous schema as part of the rollback procedure, not figure out the inverse SQL under pressure.

This lesson stays focused on the migrations workflow itself.

Installing the Tooling

EF Core's CLI lives in a separate global .NET tool called dotnet-ef. It's not part of the EF Core runtime package, which is intentional: developers install it once on their machine, and it never ships with the app. Install it once per machine:

If you've installed it before and want the latest version, use dotnet tool update --global dotnet-ef. The tool talks to your project through a separate design-time package, so your project also needs:

The Design package is what reads your DbContext, builds the model, and produces the C# migration files. It's only used at design time (when you run the CLI), not at runtime. NuGet, .NET's package manager, marks it accordingly so it doesn't get included in your published output.

A quick sanity check confirms the install:

If dotnet ef isn't found after install, your PATH doesn't include the global tools folder (~/.dotnet/tools on macOS/Linux, %USERPROFILE%\.dotnet\tools on Windows). Add it to your shell profile and reopen the terminal.

The Design package only exists for the CLI. Adding it via dotnet add package to a class library that doesn't host the DbContext ships it to production as a transitive dependency. Keep it in the startup project where the CLI needs it.

Creating Your First Migration

Once the tool is installed and the project references it, creating a migration is one command. The CLI inspects your DbContext, compares it against the last known state (or "empty" for the first migration), and generates the C# code to bridge the gap.

Imagine a small e-commerce app with this DbContext:

Run this from the project folder:

You'll find a new Migrations folder in the project, with three files inside it. Those three files are the whole point of this section, so we'll walk through each one.

The Three Generated Files

Every migrations add produces three files. They look intimidating at first, but each one has a clear job.

FilePurpose
<timestamp>_<Name>.csThe migration itself. Contains Up() (apply the change) and Down() (undo it).
<timestamp>_<Name>.Designer.csA snapshot of the model at the time this migration was created. EF uses it to detect what changed since the last migration.
ShopContextModelSnapshot.csA snapshot of the current model after all migrations have been applied. There's only one of these per context, and it gets overwritten on every migrations add.

The migration file is the one you read and review. Here's a trimmed version of what the InitialCreate migration looks like:

Up is what runs when you apply the migration. It uses the migrationBuilder to express "create this table, with these columns, with this primary key". Down is the inverse: drop the table. EF generates both directions because rollback is a first-class operation.

The Designer.cs file is a frozen copy of the model as of this migration. Don't edit it. Its only job is to let EF answer "what was the model when this migration was created?" when you generate the next migration. The model snapshot file (ShopContextModelSnapshot.cs) is similar, but represents the model after all migrations have been applied. EF compares the current C# model against this snapshot to figure out what changed when you run migrations add.

The flow looks like this:

The diagram shows the loop. Your entities change. EF compares them against the snapshot. The diff becomes a new migration. After the migration is added, the snapshot is updated to match the new state, so the next diff starts from there.

This is why deleting the snapshot file by hand breaks migrations: EF loses its memory of "what the schema should look like" and the next migrations add will think every single column is new.

Applying Migrations to the Database

Generating a migration doesn't change your database. To actually run it, use database update:

EF connects to the database, looks for a special table called __EFMigrationsHistory, sees which migrations have already run, and applies the rest in order. On a brand-new database, it creates the __EFMigrationsHistory table itself.

The history table is plain SQL and you can query it like any other:

This is how EF knows which migrations are already applied. If you copy a database to another environment, EF reads this table and applies only the new migrations.

You can also target a specific migration by name. This is useful when you want to apply migrations up to a point, but stop before later ones:

If the named migration is later than the current state, EF applies everything up to and including it. If it's earlier, EF runs the Down methods of the migrations in between to roll back. That's exactly the rollback story we'll cover in a moment.

The full set of commands you'll use day-to-day:

CommandPurposeWhen to use
dotnet ef migrations add <Name>Generate a new migration from current model changesAfter editing entities
dotnet ef migrations removeDelete the last migration (only if not yet applied to any DB)When you make a mistake before pushing
dotnet ef migrations listList all migrations and which are appliedTo check your local state
dotnet ef database updateApply all pending migrations to the databaseAfter pulling new migrations or adding your own
dotnet ef database update <Name>Apply or roll back to a specific migrationFor targeted rollback
dotnet ef migrations scriptGenerate raw SQL for all migrationsFor prod deploys via DBA review
dotnet ef database dropDrop the entire databaseLocal dev, never in prod

Most of your time will be spent on add and update. The rest are situational.

Rolling Back a Migration

Every migration has a Down method, and you can run it. There are two ways to roll back, depending on whether the migration is already applied.

If the migration hasn't been applied to any database yet (you just generated it and noticed something was off), use migrations remove:

This deletes the migration files and updates the snapshot. It's a clean undo of the last migrations add.

If the migration has already been applied to your local database, removing it directly will fail. EF refuses to remove an applied migration because the database would then be ahead of the code. The fix is to roll the database back first, then remove:

The database update <PreviousMigrationName> step runs the Down method of the most recent migration, returning the database to the named earlier state. Now the migration is unapplied, and migrations remove cleans up the files.

To roll back every migration and end up with an empty schema, use the magic name 0:

That runs every Down in reverse order. It's the closest you get to "start over" without dropping the database.

A useful mental model for the timeline:

The diagram shows the migration history as a one-dimensional timeline. Going forward applies Up methods one at a time. Going backward applies Down methods one at a time. The database always sits at one of these points, recorded in __EFMigrationsHistory.

Running Down is reliable for additive changes (drop a column EF added). It's risky for destructive changes (a Down that drops a column also throws away the data in it). Always check the generated Down before assuming a rollback is safe.

Reading a Migration: Up, Down, and migrationBuilder

The MigrationBuilder API is small and consistent. Once you've read a few migrations, you can read any of them. The most common methods:

When you add a migration, EF picks the right combination of these calls based on what changed. When you read one, you can usually understand the intent in a few seconds. Here's a migration that adds a Description column:

Up adds the column. Down drops it. EF generated both, and as long as you don't edit them, they stay in sync.

For changes EF can't figure out automatically (data conversions, conditional logic, vendor-specific SQL), you can drop raw SQL into a migration:

migrationBuilder.Sql(...) runs the SQL verbatim. Use it for data backfills, custom indexes, or any vendor-specific feature EF doesn't expose. The trade-off is that raw SQL isn't portable: a migration with SQL Server syntax won't run on PostgreSQL. If your app targets multiple databases, branch on migrationBuilder.ActiveProvider:

In practice, most teams pick one database and don't worry about this. But it's there when you need it.

Seed Data with HasData

Sometimes you want the database to start with rows already in it. Reference data like product categories, default admin users, or a list of supported currencies. EF supports this through HasData in OnModelCreating:

When you run dotnet ef migrations add SeedCategories, EF generates InsertData calls inside the migration:

Two important details. First, you must specify the primary key explicitly. Id = 1, 2, 3 is hardcoded so EF knows which rows it owns. Without an explicit Id, EF can't tell its seed rows apart from rows added by users. Second, HasData is for static reference data. If you find yourself trying to seed user-generated content, transactions, or anything dynamic, use a separate seeding routine in application code instead.

When you change a HasData entry later (say, you rename "Books" to "Books and Magazines"), EF generates an UpdateData call in the next migration. When you remove an entry, it generates DeleteData. Treat the HasData block as the source of truth for that reference data.

HasData runs as part of the migration, so it's transactional with the schema change. For seeding thousands of rows or rows that change often, it's heavyweight. Reserve it for small, stable reference tables.

Generating SQL Scripts for Production

In local dev, dotnet ef database update is fine. In production, most teams don't want a build server running migrations directly against the live database. They want a SQL script that a DBA can review, attach to a change ticket, and run during a maintenance window.

dotnet ef migrations script generates that script:

This dumps every migration's SQL to migrate.sql. By default, the script assumes the database is empty and contains every migration from the beginning. That's almost never what you want for an existing database.

The flag that changes this is --idempotent:

An idempotent script checks __EFMigrationsHistory before each migration block and only runs the ones that aren't already applied. You can run an idempotent script multiple times safely, and you can run it against databases at different migration states. Each one ends up in the same final shape.

You can also script a range, useful for "what's the SQL between the last release and this one?":

That generates the SQL needed to go from AddProductDescription to AddCategoryTable. Hand this to a DBA, get it reviewed, run it during the deploy. The C# migration files don't need to ship to production at all in this workflow.

A long-running migration locks the table being changed. On a busy production database, ALTER TABLE Products ADD COLUMN Description NVARCHAR(MAX) NULL is fast (metadata change), but ALTER TABLE Orders ADD COLUMN Total DECIMAL(18,2) NOT NULL DEFAULT 0 on a 100M-row table rewrites every page and blocks reads and writes for minutes. Test on production-sized data, and for big changes, use online-schema-change patterns (add nullable column, backfill, switch to NOT NULL) rather than one big migration.

Multiple DbContexts and the --context Flag

A real app sometimes has more than one DbContext. A common pattern is one context for the main domain (ShopContext) and a separate one for identity/auth (AuthContext). They live in the same project but manage different tables, possibly in different databases.

By default, dotnet ef errors out if it finds more than one context, because it doesn't know which one you mean:

The fix is --context on every command:

Each context gets its own Migrations folder, conventionally named Migrations/<ContextName>Migrations. To keep them tidy, pass --output-dir:

Two contexts also means two __EFMigrationsHistory tables. Each context tracks its own history. They don't conflict because they live in separate schemas or databases, and even if they share a database, each context's history table only knows about its own migrations.

Common Pitfalls

Several common mistakes come up here:

Editing a migration after it's been pushed. Once a migration has been merged and applied by someone else (or in CI), don't change its Up or Down. The migration is now part of every environment's history. Editing it means dev machines that have already run the old version see no difference, while a fresh checkout runs the new version. Schemas diverge silently. Add a new migration that makes the additional change instead.

Deleting and re-adding migrations to "clean up" history. Tempting, especially when a feature branch has five tiny migrations you'd rather see as one. Don't do it on a shared branch. The migrations folder is part of every developer's local state and every environment's history. Deleting them desyncs everyone. Squash by collapsing migrations before merging the feature branch into main, while you're still the only person touching them. After merge, treat history as append-only.

Forgetting to commit `ModelSnapshot.cs`. The snapshot is in your project's Migrations folder. If you .gitignore it (or just forget to add it), the next developer's migrations add won't know what state the model is in and will generate a migration that recreates everything from scratch. Always commit the snapshot alongside the migration file.

Running `database drop` in production. It does exactly what it says: drops the database. There's no confirmation flag. It's an excellent tool for local dev when you want to start over, and a career-ending tool if you point it at prod. The CLI uses the connection string from your config, so a stale appsettings.Production.json is enough to cause an accident. Set environment variables explicitly, and consider scripting a wrapper that refuses to run against any host that isn't localhost.

Treating migrations as DDL templates instead of git-style history. Each migration represents what happened, not what you wish had happened. If you needed to add a column, then realized you spelled it wrong and renamed it, the right history is two migrations: one that added the column, one that renamed it. Going back and editing the first migration to use the right name from the start is the equivalent of force-pushing over public history. It works for solo projects, breaks teams.

Quiz

Migrations Quiz

9 quizzes