# EF Core Migrations Best Practices in Production

> EF Core migrations are easy in development but tricky in production. Here are best practices for managing schema changes safely: idempotent scripts, migration bundles, CI/CD pipelines, and avoiding common pitfalls.

Published: 2026-08-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/ef-core-migrations-best-practices

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 executable production code with permission to reshape your data.
Treating it as generated plumbing hides locking, data-loss, and deployment-order risks until the change reaches a real database.
Safe migrations are reviewed, scripted, tested, and deployed as deliberately as the application that depends on them.

## Migrations Are Production Code

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

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**](https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-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:

```bash
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:

```bash
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

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

Migration names should describe the change. Future you will thank present you.

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

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

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

```bash
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:

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

Then in your deployment:

```bash
./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)

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

**Pros:** Simple, automatic.
**Cons:** Dangerous with multiple instances. Two instances starting simultaneously can conflict. Startup is slower. Failed migration blocks the application from starting.

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

## Handling Data Migrations

Schema migrations change structure. Data migrations change content. Keep them separate.

**Don't do this in a migration:**

```csharp
// 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 column? Easy - old code ignores it, new code uses it.

Removing a column? Dangerous - 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](https://milanjovanovic.tech/blogs/articles/ef-core-migrations-best-practices/expand-contract.png)

I go deeper on this in [**zero-downtime migrations with EF Core**](https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core), and there's a [**practical demo using password hashing**](https://milanjovanovic.tech/blog/a-practical-demo-of-zero-downtime-migrations-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, the model snapshot has conflicts

Fix: after merging, recreate Developer B's migration:

```bash
# Remove B's migration
dotnet ef migrations remove

# Add it back (now based on A's snapshot)
dotnet ef migrations add AddPaymentMethod
```

**Prevention:** coordinate migrations. Only one migration branch should be in-flight at a time, or use a migration lock file.

## Seeding Reference Data

Use migrations for reference data that your application requires:

```csharp
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 - not migrations.
I compare the options (including `UseSeeding` from EF Core 9) in [**seeding data with EF Core**](https://milanjovanovic.tech/blog/seeding-data-ef-core).

## Index Management

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

```csharp
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:

```csharp
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 rather than making the build free of 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`.

## My Recommended Pipeline

![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](https://milanjovanovic.tech/blogs/articles/ef-core-migrations-best-practices/deploy-pipeline.png)

The key: **migrations run before the application deploys**. 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 letting every application instance race 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 because concurrent instances can conflict.

### 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 script can safely run against a database in any state, and running it twice is harmless.

### Should EF Core migrations run automatically on application startup?

Only for single-instance deployments or development. With multiple instances, simultaneous startups can race on schema changes, 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?

Remove the migration created on your branch with dotnet ef migrations remove and re-add it so it is based on the merged model snapshot. Coordinating who has a migration in flight prevents most conflicts.
