You're Probably Doing EF Migrations wrong... (English)

You're Probably Doing EF Migrations wrong...

Tuesday, 27 October 2026

//

21 minute read

Updated - 27 October 2026. Original was published Nov 2025 - Corrected bundle options and added current EF Core 9/10 guidance, Aspire integration and clearly marked EF Core 11 preview features. See the errata at the end for corrections to the original version.

Running MigrateAsync() at startup? You're giving your app schema-changing permissions and hoping nothing goes wrong. There's a better way - EF migration bundles let you run migrations as a controlled CI step, keeping your production app secure. But here's the thing: sometimes the "wrong" way is actually fine. Let's explore when to use each approach.

Official docs: Migrations Overview | Applying Migrations | Bundles

The "Wrong" Way (That I Use)

This blog uses MigrateAsync() at startup - the approach I'm about to tell you not to use. Here's why that's okay for me, and why it probably isn't for you.

In my Program.cs file I have the following:

    using (var scope = app.Services.CreateScope())
    {
        var blogContext = scope.ServiceProvider.GetRequiredService<IMostlylucidDBContext>();
        await blogContext.Database.MigrateAsync();
    }

MigrateAsync() applies pending migrations and creates the database if needed. Simple - but problematic:

  1. Startup dependency - Database down? Migration fails? Your app won't start.
  2. Elevated permissions - Your app needs the DDL permissions required by its migrations, potentially including dropping tables. That does not necessarily mean the SQL Server db_owner role, but it is still more power than ordinary application queries need.

Why I get away with it: public data, single Docker network, personal project. You probably can't.

When Runtime Migrations Are Fine

  • Local dev - Fast iteration beats ceremony
  • Personal projects - Low blast radius, no sensitive data
  • Docker-compose dev environments - Convenience wins
  • Prototyping - Schema is changing constantly anyway

When They're Not

  • Multiple app instances - Startup migrations couple every replica to schema deployment. EF Core 9+ adds migration locking, but that does not coordinate old application code with a changing schema
  • Sensitive data - PII, financial, regulated = proper separation required
  • Production with real users - Failed migration = outage

The Right Way: EF Bundles

An EF bundle is an executable containing your compiled migrations. Think dotnet ef database update packaged as a deployment artifact. It is framework-dependent by default; add --self-contained to include the .NET runtime.

Why bundles win:

  • No SDK or EF CLI on the target - A self-contained bundle also includes the .NET runtime; OS/native dependencies still apply
  • Proper separation - A separate deployment identity gets the necessary schema permissions; the runtime app keeps its normal data permissions
  • CI visibility - Failures show in pipeline logs, not buried in app startup
  • Deployment gate - A nonzero bundle exit stops deployment. That is not a guarantee that every database change was rolled back
  • Migration history - Tracks which migrations have been applied. Custom SQL and seeding still need careful retry behaviour

Identity matters: Where your database supports it, managed identity can remove stored passwords. You still need connection settings and separate application/deployment permissions. Bundles provide a place to enforce that separation.

GitHub Actions Example

Commit a local dotnet-ef tool manifest pinned to the EF version you use, and include Microsoft.EntityFrameworkCore.Design in the startup project. The build job restores tools and creates the artifact; the protected deployment job receives the production secret. These steps assume checkout and the appropriate .NET SDK are already configured.

# Build job: no production credentials
- name: Restore EF tools
  run: dotnet tool restore

- name: Generate Linux migration bundle
  shell: bash
  run: |
    dotnet restore
    dotnet ef migrations bundle \
      --project Mostlylucid.DbContext \
      --startup-project Mostlylucid \
      --context MostlylucidDbContext \
      --configuration Release \
      --target-runtime linux-x64 \
      --self-contained \
      --output efbundle
  env:
    DOTNET_ENVIRONMENT: Production
    ASPNETCORE_ENVIRONMENT: Production

- uses: actions/upload-artifact@v4
  with:
    name: ef-migrations
    path: efbundle

# Deployment job: after downloading the same tested artifact
- uses: actions/download-artifact@v4
  with:
    name: ef-migrations

- name: Apply migrations
  shell: bash
  run: |
    chmod +x ./efbundle
    ./efbundle --connection "$MIGRATION_CONNECTION_STRING" --no-color --prefix-output
  env:
    DOTNET_ENVIRONMENT: Production
    ASPNETCORE_ENVIRONMENT: Production
    MIGRATION_CONNECTION_STRING: ${{ secrets.PROD_SQL_CONNECTIONSTRING }}

Use your actual context name. Explicitly selecting Production also avoids accidentally loading development configuration. Design-time context creation still needs valid provider configuration; arrange a nonproduction build setting if your startup or factory requires a connection string. It should not need production credentials just to generate migrations.

Promote the same artifact through test, staging and production. Use a protected GitHub environment and serialize deployments to the same database. If context creation reads settings files, ship the required nonsecret files with the bundle too. See Microsoft's deployment and configuration guidance.

Local Bundles

No CI? Want to test before pushing? Build bundles locally.

Use cases: Test before CI, DBA handoff (self-contained exe, no SDK needed), staging deploys, debugging with --verbose.

Creating a Bundle

# Once per repository: create a tool manifest if you do not already have one
dotnet new tool-manifest
# Example version: choose the supported patch matching your EF packages
dotnet tool install dotnet-ef --version 10.0.12
# In CI or after cloning
dotnet tool restore

# Basic bundle
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle

# Self-contained (includes runtime for the selected OS/architecture)
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle \
    --self-contained

# Cross-platform (e.g., build on Windows, deploy to Linux)
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle \
    --target-runtime linux-x64

Running Your Bundle

The following uses a Unix shell. On Windows, build with --output efbundle.exe and run .\efbundle.exe in PowerShell.

# Using the connection configured by your DbContext
./efbundle

# Override with a specific connection string
./efbundle --connection "$MIGRATION_CONNECTION_STRING"

# Or use an environment variable matching the key your context reads
export ConnectionStrings__DefaultConnection="$MIGRATION_CONNECTION_STRING"
./efbundle

For the last example in PowerShell:

$env:ConnectionStrings__DefaultConnection = $env:MIGRATION_CONNECTION_STRING
.\efbundle.exe

Useful Bundle Options

There are two different command surfaces: dotnet ef migrations bundle builds the executable; ./efbundle runs it. Their options are not interchangeable.

When building Purpose
--output, -o Output file path
--force, -f Replace an existing bundle file
--self-contained Include the .NET runtime
--target-runtime, -r Deployment RID, such as linux-x64 or linux-musl-x64
--project, --startup-project, --context Select migrations, startup configuration and context
--configuration, --framework Select build configuration and target framework
--no-build Reuse an up-to-date project build; not permission to bundle stale binaries

The tools also have a common --runtime option. Use the bundle-specific --target-runtime to make the deployment target explicit. Self-contained does not mean one binary works on Windows, Linux and macOS; build for the actual target, including architecture and libc.

./efbundle --help
./efbundle --version

# Apply pending migrations, with deployment-friendly logs
./efbundle --verbose --no-color --prefix-output

# Target is positional: this is NOT --target-migration
./efbundle 20231115_AddUserTable

# Revert everything: destructive, only for a disposable test database
./efbundle 0

The executable supports --connection, --verbose/-v, --no-color, --prefix-output, help and version. Its optional argument is the migration that should remain applied. The EF bundle command source shows the actual parser.

There Is No Bundle Dry Run

./efbundle --dry-run does not exist. Nor does --verbose turn execution into a preview. Generate SQL without applying it:

dotnet ef migrations script PreviousMigration AddNewFeature \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output migrations.sql

Use 0 as the first argument for a fresh database. Script generation does not test constraints against your real data. A restored, sanitised database copy is where you find those problems.

To see the project's migrations and which are pending against the configured database:

dotnet ef migrations list \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid

Add --no-connect to list the assembly's migrations without contacting the database; that cannot tell you which are applied. This is a CLI command, not a bundle option.

Local Testing Workflow

# 1. Create and inspect the migration
dotnet ef migrations add AddNewFeature \
    --project Mostlylucid.DbContext --startup-project Mostlylucid

# 2. Generate reviewable SQL for the expected starting point
dotnet ef migrations script PreviousMigration AddNewFeature \
    --project Mostlylucid.DbContext --startup-project Mostlylucid \
    --output migrations.sql

# 3. Build the bundle for this test host
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext --startup-project Mostlylucid \
    --output efbundle --force

# 4. Apply to a disposable database or restored test copy
./efbundle --connection "$TEST_MIGRATION_CONNECTION_STRING" --verbose

# 5. Run again to verify migration history/retry behaviour
./efbundle --connection "$TEST_MIGRATION_CONNECTION_STRING"

Test both a fresh database and an upgrade from a representative existing database. That exercises schema changes, constraints and existing data before production.

If the newest migration needs reworking and has only been applied locally, first update that local database to PreviousMigration. Then use dotnet ef migrations remove with the same project/context arguments. Removing a migration edits source; it is not a general production rollback strategy. Once released, prefer a new corrective migration.

Build Performance

Bundle generation can be slow, especially for self-contained targets. Don't generate on every build.

  • Generate manually when testing locally
  • Build the release bundle in CI and run migration checks on PRs
  • Cache using all relevant inputs: migrations, model, configuration/factory code, dependencies and target RID

If you really want auto-generation, add a MSBuild target:

<Target Name="BuildMigrationBundle">
  <Exec Command="dotnet ef migrations bundle --output $(OutputPath)efbundle.exe --force" />
</Target>

Then: dotnet build -t:BuildMigrationBundle

Hybrid Approach

Best of both worlds: convenience locally, security in production.

if (builder.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<IMostlylucidDBContext>();
    await context.Database.MigrateAsync();
}
// Production: CI pipeline runs the bundle

Alternatives to Bundles

SQL Scripts

Generate plain SQL instead of an executable. Great for DBA review and existing change management processes.

# All migrations
dotnet ef migrations script --output migrations.sql

# Idempotent: checks migration history, when supported by the provider
dotnet ef migrations script --idempotent --output migrations.sql

# Range of migrations
dotnet ef migrations script FromMigration ToMigration --output migrations.sql

Pros: Full visibility, any SQL client can run it, version control friendly, DBA approval workflows.

Cons: Your runner must handle failures correctly, and a range script assumes the expected starting migration. Generated scripts update EF migration history too; --idempotent adds conditional checks rather than inventing history tracking. SQLite does not support idempotent migration scripts.

See official docs on SQL scripts.

SQL Scripts in CI

For SQL Server, make SQL errors fail the pipeline:

sqlcmd -S "$DB_SERVER" -d "$DB_NAME" -b -i migrations.sql

For PostgreSQL, do the same explicitly:

psql --host="$DB_HOST" --username="$DB_USER" --dbname="$DB_NAME" \
    --set=ON_ERROR_STOP=1 --file=migrations.sql

Supply credentials through the runner's supported secure mechanism. These are alternatives for the provider that generated the SQL, not two commands to run against the same database. See sqlcmd and psql error handling. Don't blindly add another transaction wrapper: generated scripts may already manage transactions, and some operations cannot run inside one.

DACPAC (SQL Server Only)

DACPACs are state-based not migration-based. You define the desired schema, and SqlPackage diffs it against the target database.

SqlPackage.exe /Action:Publish /SourceFile:MyDatabase.dacpac /TargetConnectionString:"..."

Pros: Schema as code, auto-diff generation, covers tables, views, stored procedures and indexes, enterprise tooling.

Cons: SQL Server only, schema in two places (EF models + SQL project), diff engine makes questionable choices, column renames look like drop+add.

See SqlPackage docs.

Comparison Table

Approach Best For Requires .NET Auto-tracks Applied DBA Friendly Cross-platform DB
MigrateAsync() Dev/small projects Yes (runtime) Yes No Yes
EF Bundles CI/CD pipelines No (self-contained) Yes Somewhat Yes
SQL Scripts DBA-controlled environments No Writes history; conditional with --idempotent Yes Provider-dependent
DACPAC SQL Server enterprise No EF runtime Compares schema, not EF history Yes No

Tips

The Designer File Gotcha

Migrations work locally but not in CI? Check you committed all three generated pieces:

  • 20231115_AddUserTable.cs - The migration code
  • 20231115_AddUserTable.Designer.cs - Metadata and the target model for that migration
  • BlogDbContextModelSnapshot.cs - The current model snapshot used to scaffold the next migration

Missing designer metadata can stop EF discovering the migration; a missing or stale snapshot causes incorrect future diffs. They are different files with different jobs. See managing migrations.

Don't mix creation strategies: EnsureCreated() bypasses migrations and their history. Use it for disposable databases that will stay on that strategy; don't put it before MigrateAsync() and expect EF to adopt the resulting schema. EF database creation guidance.

Multiple DbContexts

dotnet ef migrations bundle --context BlogDbContext --output blog-migrations.exe
dotnet ef migrations bundle --context IdentityDbContext --output identity-migrations.exe

Connection String Priority

The bundle's --connection overrides the connection configured for the context. Otherwise, precedence belongs to your application's configuration pipeline. With the default ASP.NET Core builder, environment variables override JSON, but custom providers or a design-time factory can change that.

ConnectionStrings__DefaultConnection only works if your context actually reads ConnectionStrings:DefaultConnection; the AdminSite__ConnectionString used by the factory below reads a different key. Environment variables are configuration inputs, not a special bundle connection-string lookup. See configuration providers and ordering.

IDesignTimeDbContextFactory

EF tools need to instantiate your DbContext. If your DbContext is in a separate project or has complex startup, implement IDesignTimeDbContextFactory<T>:

public class AdminDbContextFactory : IDesignTimeDbContextFactory<AdminDbContext>
{
    public AdminDbContext CreateDbContext(string[] args)
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: true)
            .AddUserSecrets<AdminDbContextFactory>(optional: true)
            .AddEnvironmentVariables()
            .Build();

        var connectionString = config["AdminSite:ConnectionString"]
            ?? throw new InvalidOperationException("Missing connection string");

        var optionsBuilder = new DbContextOptionsBuilder<AdminDbContext>();
        optionsBuilder.UseSqlServer(connectionString, sql => sql.CommandTimeout(120));

        return new AdminDbContext(optionsBuilder.Options);
    }
}

Use when: DbContext in a separate project, complex startup, or controlled context creation for tooling. EF uses a discovered factory instead of normal startup-based creation, including in a bundle. Keep the provider/model consistent with the app. This minimal factory deliberately loads only base JSON, optional user secrets and environment variables; add environment-specific files explicitly if needed, and keep production secrets out of the artifact. Environment variables are last here so CI can override local secrets.

What Has Changed Around Migrations?

The core advice survives newer EF releases: build a repeatable artifact and give schema deployment its own identity. But several details have improved since the original article.

EF Core 9+: Locking, Seeding and Model Checks

EF Core 9 introduced migration locking to protect concurrent migration execution. That fixes an important old race; it does not make a destructive schema change compatible with requests still running on old code. Separate deployment is still useful. EF Core 9 migration locking.

EF Core 9 also starts rejecting migration application when the model has changes missing from the snapshot. Check earlier in CI using the command introduced in EF Core 8:

dotnet ef migrations has-pending-model-changes \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid

That checks model versus snapshot, not whether production has applied every migration. It is not schema drift detection. Nondeterministic HasData values such as DateTime.Now or Guid.NewGuid() can produce repeated model changes; fix those rather than suppressing the check. EF Core 9 breaking changes.

UseSeeding and UseAsyncSeeding, introduced in EF Core 9, run under migration locking. Tooling and bundles use the synchronous hook, so implementing only the async one is insufficient. Seeding can execute even when there are no pending migrations: a second bundle run is not necessarily a no-op. Check for existing data and make the operation repeatable. Generated SQL scripts do not run these C# delegates. Seeding behaviour.

EF Core 10: Watch Default Constraint Naming

EF Core 10 is the current stable LTS release and requires .NET 10. One migration-specific addition for SQL Server is explicit default-constraint naming:

modelBuilder.Entity<Blog>()
    .Property(b => b.Rating)
    .HasDefaultValue(3, "DF_Blog_Rating");

There is also modelBuilder.UseNamedDefaultConstraints(). Enabling it on an existing model causes the next migration to rename every default constraint in that model. A tiny configuration change can therefore produce a large migration. Review the diff before deploying. Named default constraints; EF Core 10 release details.

Aspire Can Own the Orchestration

Aspire now has a first-class migrations resource in Aspire.Hosting.EntityFrameworkCore. In an AppHost, local development can express the dependency directly:

var migrations = api.AddEFMigrations("api-migrations")
    .RunDatabaseUpdateOnStart();

api.WaitForCompletion(migrations);

RunDatabaseUpdateOnStart() is local-run behaviour. For publishing, PublishAsMigrationBundle() and PublishAsMigrationScript() produce deployment artifacts; the bundle can also be published as a container. Use a one-shot job rather than a permanently restarting service. This is orchestration around the same deployment separation, without hand-rolling a migration worker project. Aspire migration integration.

EF Core 11 Preview: Keep These Separate

As of this update, EF Core 11 is still in development. The current documentation includes features that are not EF Core 10 options:

  • .config/dotnet-ef.json supplies repeated CLI defaults such as project, startup project and context. Explicit CLI arguments take precedence.
  • dotnet ef migrations remove --offline removes the latest migration without a database connection. You must know it has not been applied; it cannot be combined with --force.
  • migrations remove --connection selects the database connection explicitly.
  • dotnet ef database update AddFeature --add scaffolds, compiles and applies a migration together. Useful development convenience, not a replacement for reviewing release migrations.

See the version-labelled CLI reference and create-and-apply documentation. Pin your tools rather than assuming every option on the latest docs page exists in your installed release. None of these adds a bundle dry-run option.

What About...?

Common questions and pushback I've received.

"Why not just run dotnet ef database update in CI?"

Covered above, but the short version: bundles are portable artifacts. Your deployment step doesn't need EF CLI, source code, or design-time resolution. Same bundle runs in test, staging, and prod - the same compiled migrations. Connection configuration and actual database drift still need checking.

"Isn't this overkill for a small app?"

Maybe. If you're solo, data is public, and blast radius is low - MigrateAsync() is fine. But the moment you add a second developer, sensitive data, or multiple environments, bundles pay for themselves.

"What about rollbacks?"

A failed migration normally benefits from EF/provider transaction handling, but some operations run outside transactions. That is different from deliberately downgrading a successful release. Options:

  • Run the bundle with an earlier migration as its positional target. EF executes the existing newer migrations' Down() methods; it does not generate a new "Down migration"
  • Restore a tested backup when data needs recovering
  • Write and deploy a new corrective migration

Reverting a dropped column does not restore its contents. Review and test Down() just as carefully as Up().

For critical systems: test migrations against a database clone first.

"Can I run migrations in a Kubernetes init container?"

An init container can run a bundle, but it runs once per pod, not once per deployment. For replicated production workloads, prefer a dedicated migration Job with a deployment gate. For a single-pod example:

initContainers:
  - name: migrate
    image: myapp:latest
    command: ["./efbundle"]
    env:
      - name: ConnectionStrings__Default
        valueFrom:
          secretKeyRef:
            name: db-secrets
            key: connection-string

The app container waits for successful init completion. The image must contain the Linux bundle and its required configuration; use the separate migration identity here, not the app's regular credentials.

"What about FluentMigrator / DbUp / other tools?"

They work great. EF bundles are the EF-native solution, but FluentMigrator and DbUp have their fans. Key difference: those are migration-specific tools, while EF bundles come from your existing EF model.

"My DBA wants to review all SQL before it runs"

Use --idempotent scripts:

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

DBA reviews and approves. If approval is for the exact SQL, execute that reviewed script and archive it with the release. A bundle generates SQL at execution time and can also run configured seeding code; it is not an execution wrapper for your approved file.

"How do I handle migrations with zero downtime?"

That's a deployment strategy question, not a migrations question. Generally:

  1. Expand the schema with changes the old app can tolerate, such as a new nullable column
  2. Deploy code that handles the transition, including dual writes where needed
  3. Backfill existing data and verify it
  4. Switch reads and constraints once both code and data are ready
  5. Contract later, removing old columns only after old instances have drained

Bundles don't solve this - they provide a controlled way to deliver the schema steps. Lock duration, table rewrites and backfills still need measuring.

Errata

The original version included several incorrect commands and some claims that were too broad. These are corrected above; this records what changed.

  • No bundle dry run. efbundle --dry-run is not supported. Generate a SQL script for review, then test against a disposable database or restored copy. --verbose still executes migrations.
  • The target migration is positional. Use efbundle PreviousMigration, not --target-migration. Target 0 reverts all migrations by executing their Down() methods.
  • Self-contained is opt-in. A default bundle still needs a compatible .NET runtime. --self-contained includes it for one target RID; it does not remove OS/native dependencies.
  • Schema permissions are not synonymous with db_owner. Grant the deployment identity the permissions its migrations require, rather than assuming a particular SQL Server role.
  • A deployment gate is not automatic recovery. A failing bundle stops deployment, but transaction-suppressed operations and data loss need their own recovery plan. Removing a migration from source is not a production rollback.
  • The designer is not the snapshot. Commit the migration, its .Designer.cs metadata and the separate context model snapshot.
  • Scripts do track migrations. Generated SQL updates migration history. --idempotent adds checks for already-applied migrations; support depends on the provider, and SQLite cannot generate those scripts.
  • Connection configuration is application-defined. Environment variables only affect the connection if the context/factory reads the matching key. The factory example now lets environment variables override optional user secrets.
  • Migration locking changes the concurrency advice. EF Core 9+ protects migration execution with a lock. It does not coordinate live requests or make breaking schema changes safe for old application versions.
  • An init container is per pod. It is not a once-per-release migration job. Use a dedicated job and deployment ordering for replicated workloads.
  • A bundle does not execute your reviewed SQL file. Where approval covers exact SQL, deploy that artifact. Bundles may also run configured seeding, including when no migrations remain.

The new EF Core 11 items are separately labelled preview features. They should not be read as options available in the EF Core 10 examples.

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.