EF Core Migrations Best Practices in Production

devopsdotnetef-core
EF Core Migrations Best Practices in Production

Apply EF Core migrations in production from the deployment pipeline, using an idempotent SQL script or a migration bundle, and run that step before the application deploys. Keep each migration to one logical change and review the generated SQL before it runs. For changes that break old code, expand the schema first and contract it only after every deployed instance has moved to the new shape.

A migration is production code that can change both your schema and your data. If you ship the generated code without reviewing it, locking, data-loss, and deployment-order risks stay hidden until the change reaches a real database.

Migrations Are Production Code

EF Core migrations work great on your local machine. You run dotnet ef database update and the schema changes.

Production is different. You have:

  • Multiple instances running the same database
  • Zero-downtime deployments to maintain
  • Rollback scenarios to plan for
  • Data to preserve (you can't just drop and recreate)
  • Team members creating conflicting migrations

EF Core migrations need the same discipline as any other production code. If you need a refresher on how migrations work mechanically, start with my detailed EF Core migrations guide. This article focuses on running them safely in production.

Creating Clean Migrations

One Migration Per Logical Change

Don't combine unrelated schema changes. If you're adding a column to Orders and creating a new Payments table, make two separate migrations:

dotnet ef migrations add AddShippingAddressToOrders
dotnet ef migrations add CreatePaymentsTable

Smaller migrations are easier to review, easier to roll back, and cause fewer merge conflicts.

Review Generated SQL

Always inspect what EF Core generates:

dotnet ef migrations script --idempotent

The idempotent flag checks the migrations history table and skips migrations that completed successfully. It does not make every statement independently retryable: a transaction-suppressed operation can succeed even if the migration later fails before its history row is recorded.

Review the output for:

  • Unexpected DROP statements
  • Missing indexes
  • Data loss operations (column type changes, table drops)

Name Migrations Descriptively

✗ Migration1, Update2, Fix3
✓ AddShippingAddressToOrders, CreatePaymentMethodsTable, AddIndexOnOrderStatus

Migration names should describe the change.

How Should You Apply Migrations in Production?

Option 1: SQL Scripts in CI/CD

Generate an idempotent SQL script and run it as a deployment step:

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

Then apply it (with psql for PostgreSQL, or the equivalent client for your database):

psql "$DATABASE_CONNECTION_STRING" -f migrations.sql

Pros: Full control, works with any deployment tool, can be reviewed before execution. Cons: Manual pipeline setup.

Option 2: Migration Bundles

EF Core 6+ supports migration bundles (self-contained executables that apply migrations):

dotnet ef migrations bundle --self-contained -o efbundle

Then in your deployment:

./efbundle --connection "Server=myserver;Database=mydb;..."

Pros: No SDK needed at deployment time, single file, idempotent by default. Cons: Bundle needs to match your target runtime.

Option 3: Migrate on Startup (Use With Caution)

using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync();

Pros: Simple, automatic. Cons: Before EF Core 9, two instances starting simultaneously can conflict. EF Core 9 and later take a database-wide migration lock, so instances wait for each other instead. Startup is slower. Failed migration blocks the application from starting.

I only recommend this for single-instance deployments or development environments.

Handling Data Migrations

Keep schema migrations (which change structure) separate from data migrations (which change content).

Don't do this in a migration:

// Bad - mixing schema and complex data changes
migrationBuilder.Sql(@"
    UPDATE Orders
    SET Status = CASE
        WHEN OldStatus = 1 THEN 'Draft'
        WHEN OldStatus = 2 THEN 'Confirmed'
        ELSE 'Unknown'
    END;
    ALTER TABLE Orders DROP COLUMN OldStatus;
");

Do this instead:

  1. Migration 1: Add new column
  2. Deploy data migration script (or background job)
  3. Migration 2: Drop old column

This multi-step approach is safer because you can verify the data migration worked before dropping the old column.

Zero-Downtime Migrations

Adding a nullable column (or one with a default value) is easy, because old code ignores it and new code uses it.

Removing a column is dangerous, because old code might still reference it during rolling deployments.

The safe pattern for breaking changes:

Step 1: Expand. Add the new column/table. Deploy code that writes to both old and new.

Step 2: Migrate. Backfill data from old to new.

Step 3: Contract. Deploy code that only uses the new column. Then drop the old one.

This is called the expand-contract pattern. It ensures both the old and new versions of your application work at every step.

Three-step expand-contract pattern: expand by adding the new column and writing to both, migrate by backfilling old to new, then contract by using the new column only and dropping the old one

I go deeper on this in zero-downtime migrations with EF Core, and there's a practical demo using password hashing if you want to see the pattern applied end to end.

Handling Merge Conflicts

When two developers create migrations from the same base:

  1. Developer A creates AddShippingAddress (snapshot → A)
  2. Developer B creates AddPaymentMethod (snapshot → B)
  3. When merged, B's migration and snapshot don't include A's changes (on EF Core 10 and earlier, Git may merge the snapshot file without reporting a conflict)

Fix: recreate Developer B's migration on top of A's. Abort the merge, delete B's generated migration files, and restore the model snapshot from before B's migration, keeping B's entity and configuration changes. Then merge A's changes and add the migration again:

# Add it back (now based on the merged snapshot)
dotnet ef migrations add AddPaymentMethod

Don't run dotnet ef migrations remove after the merge. It restores the snapshot from the previous migration's metadata, which may not include the other branch's changes.

Prevention: coordinate migrations. Only one migration branch should be in-flight at a time.

Seeding Reference Data

Use migrations for reference data that your application requires:

migrationBuilder.InsertData(
    table: "OrderStatuses",
    columns: new[] { "Id", "Name" },
    values: new object[,]
    {
        { 1, "Draft" },
        { 2, "Confirmed" },
        { 3, "Shipped" },
        { 4, "Delivered" },
        { 5, "Cancelled" }
    });

For large datasets or dynamic data, use a separate seeding tool or script instead of migrations. I compare the options (including UseSeeding from EF Core 9) in seeding data with EF Core.

Index Management

Always add indexes through migrations. EF Core creates some automatically, but you should be explicit about performance-critical ones:

migrationBuilder.CreateIndex(
    name: "IX_Orders_CustomerId_Status",
    table: "Orders",
    columns: new[] { "CustomerId", "Status" });

migrationBuilder.CreateIndex(
    name: "IX_Orders_CreatedAt",
    table: "Orders",
    column: "CreatedAt",
    descending: new[] { true });

Create indexes concurrently on PostgreSQL when a normal build would block writes:

migrationBuilder.Sql(
    @"CREATE INDEX CONCURRENTLY ""IX_Orders_CreatedAt""
      ON ""Orders"" (""CreatedAt"" DESC);",
    suppressTransaction: true);

PostgreSQL rejects CREATE INDEX CONCURRENTLY inside a transaction, so suppressTransaction: true is required. Put this operation in a dedicated migration and do not let the deployment runner wrap the generated script in an outer BEGIN/COMMIT block. Concurrent creation performs extra scans and can wait on long-running transactions, so it avoids blocking writes but still has operational impact.

Transaction suppression also makes the migration non-atomic. If the build or a later command fails, PostgreSQL can leave an invalid or already-created index while EF has no migration-history row. Inspect the index state and drop an invalid index before retrying; do not hide the mismatch with IF NOT EXISTS.

Recommended deployment pipeline: developer creates a migration locally, the PR includes the migration and reviewed SQL, CI applies it to a test database, the pipeline generates an idempotent script that runs against staging then production, and only then the application deploys

Migrations run before the application deploys, so the database schema is always ahead of the application code.

Summary

Keep each migration focused, review the generated SQL, and test it against a realistic copy of the database. Deploy idempotent scripts or bundles from the pipeline rather than having every application instance migrate at startup. For breaking changes, expand the schema first and remove the old shape only after all deployed code has moved away from it.

Frequently Asked Questions

How should I apply EF Core migrations in production?

Generate an idempotent SQL script or a migration bundle and run it as a deployment step before the application deploys. Avoid MigrateAsync on startup in multi-instance environments: before EF Core 9 concurrent instances can conflict, and a failed migration still blocks every instance from starting.

What does the --idempotent flag do in dotnet ef migrations script?

It generates SQL that checks the migrations history table before applying each migration, so the same script can run against a database at any migration and applies only the missing ones. It does not make a migration that failed partway safe to retry.

Should EF Core migrations run automatically on application startup?

Only for single-instance deployments or development. Before EF Core 9, simultaneous startups can race on schema changes (EF Core 9 and later take a database-wide migration lock), and a failed migration prevents the app from starting.

How do I do zero-downtime migrations with EF Core?

Use the expand-contract pattern: add the new schema first, deploy code that works with both old and new, backfill data, then remove the old schema in a later release. Every step keeps both app versions working.

How do I fix migration conflicts after a git merge?

Abort the merge, delete the migration files your branch created and restore the snapshot from before them (keep your model changes), merge, then add the migration again so it is based on the merged model snapshot. Do not run dotnet ef migrations remove after the merge, because it restores a snapshot that may not include the other branch's changes. Coordinating who has a migration in flight prevents most conflicts.

  • EF Core Migration Bundles for CI/CD Deployments

    Calling Database.Migrate at startup means your schema changes run when the app boots, with app instances racing each other and failures taking production down. Migration bundles package migrations into a self-contained executable that runs as a pipeline step, so a bad migration fails the deploy, not the app.

  • How to Roll Back an EF Core Migration

    Rolling back an EF Core migration is two commands, but only in the right order. Delete the migration file first and EF Core loses the ability to generate the Down SQL, leaving your database and model out of sync. Here is the safe sequence for local, shared, and production databases.

  • Zero-Downtime Database Migrations With EF Core

    Deploying database changes without downtime means old and new application code must both work against the same schema. The expand-contract pattern, additive-only migrations, and backwards-compatible changes make that possible with EF Core.

Browse all EF Core guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.