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 - 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
DROPstatements - 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. 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:
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: 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:
// 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:
- Migration 1: Add new column
- Deploy data migration script (or background job)
- 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.
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:
- Developer A creates
AddShippingAddress(snapshot → A) - Developer B creates
AddPaymentMethod(snapshot → B) - When merged, the model snapshot has conflicts
Fix: after merging, recreate Developer B's migration:
# 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:
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.
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 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
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.



