DEV Community

John Ntirintis
John Ntirintis

Posted on

EF Core Migration Bundles: What They Are & Why They Matter

EF Migrations Bundle Hero Image

TL;DR

  • A migration bundle packages every pending migration for one DbContext into a single, runtime-targeted executable. No source code or dotnet-ef tool needed on the target machine.
  • They exist to get schema changes out of your running application and into a single, deliberate, auditable step, which is what makes them safe for production, CI/CD, and multi-service/shared-database setups.
  • They're idempotent, one-per-context, and need an appsettings.json sitting next to them with the right keys present (not necessarily real values).
  • The tool sometimes logs in red when there's nothing to do. That's not a failure, check the last line.

Prerequisites

This assumes you're already using EF Core Migrations (dotnet ef migrations add, dotnet ef database update) or at least have some familiarity with it and just haven't used bundles yet. If you're new to EF Core Migrations entirely, get comfortable with the basic workflow first, bundles are a deployment mechanism on top of migrations you've already created, not a replacement for them!

What Exactly Is a Migration Bundle

A bundle is a self-contained executable that packages every pending EF Core migration for a single DbContext, targetable at a specific runtime (win-x64, linux-x64, osx-x64, etc.). Build it once, and run it anywh-... oh wait thats Java, whoops! What i meant to say is, you only have to bundle it once, and you can run it against a database and you are done.

dotnet ef migrations bundle --self-contained -r linux-x64 --context OrdersDbContext -o ./bundles/orders/efbundle
Enter fullscreen mode Exit fullscreen mode

OR, for windows:

dotnet ef migrations bundle --self-contained -r win-x64 --context OrdersDbContext -o ./bundles/orders/efbundle.exe
Enter fullscreen mode Exit fullscreen mode

That's it! --context picks the DbContext, -r picks the target platform, --self-contained decides whether the .NET runtime ships inside the executable. There are a few more flags worth knowing once you're building these for real projects and CI/CD; full breakdown further down, in Anatomy of the Build Command.

Running it (in a windows system) is just as simple:

./efbundle.exe --connection "Server=prod-db;Database=Orders;User Id=deploy_user;Password=***"
Enter fullscreen mode Exit fullscreen mode

The Problem Bundles Actually Solve

EF Core lets you apply migrations straight from your application at startup, via context.Database.MigrateAsync(). It's the path of least resistance, and it's genuinely fine for local development. In production, it falls apart for a few concrete reasons:

  • Run more than one instance of the same app, and more than one of them can try to apply the same migration at the same time. Prior to EF Core 9 there was no built-in protection against this, two instances racing to alter the schema simultaneously is how you get deadlocks or, worse, a half-migrated table.
  • The app process needs elevated permissions to alter schema, which cuts against the normal practice of running production workloads with the least privilege they need.
  • You lose the ability to inspect or roll back the exact SQL before it hits production, the migration just runs, whatever it does, whenever the app happens to boot.

EF Core 9 introduced a database-wide lock that MigrateAsync, Migrate, dotnet ef database update, and bundles all now respect, so two instances can no longer stomp on each other mid-migration. That closes the worst failure mode, but it doesn't fix the underlying architecture: with runtime migrations, your application process is the thing that owns and executes schema changes, on every boot, on every instance. A bundle is a separate, deliberate, one-shot execution instead, it runs once, sequentially, outside your app's lifecycle, and it's done.

This matters even more once a database is shared across services! A modular monolith with several bounded contexts against one schema, or a set of microservices where no single service should unilaterally own schema changes. In that setup you don't want N running instances each independently deciding to call MigrateAsync against a schema they don't exclusively own. A bundle is a single, auditable, out-of-band operation you run against that shared environment. Deploy it, run it once and move on.

One Bundle, One Context, No Exceptions

A bundle maps to exactly one DbContext. There's no such thing as a single bundle that rolls up migrations for multiple contexts. If you have several (common in modular applications), you build one bundle per context, using --context to specify it at build time. Design your build/deploy scripts around that from day one; it doesn't collapse down later no matter how many contexts you accumulate.

The Misleading "Error" That Isn't One

Run a bundle against a database that's already fully migrated, and it logs its findings in red text that reads like a failure before finishing with Done. Here's an actual run:

An error occurred using the connection to database 'Platform' on server 'tcp://localhost:1234'.
No migrations were found in assembly 'Contoso.Platform.Modules'. A migration needs to be added before the database can be updated.
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
No migrations were applied. The database is already up to date.
Done.
Press any key to continue . . .
Enter fullscreen mode Exit fullscreen mode

Two things worth clocking here. First, that opening line ("An error occurred using the connection to database...") reads like a connection failure. It isn't one, it's just the tool's log-level framing for "here's what I found when I connected." Second, this particular run also shows the EF Core 9 locking behavior mentioned earlier in practice: the "Acquiring an exclusive lock for migration application" line is the bundle taking that database-wide lock before it does anything, exactly as documented.

The tell is always the final line. If it says Done., the bundle succeeded regardless of what the red text above it says.

The appsettings.json confusion (at least for me)

The bundle needs an appsettings.json sitting next to it in the execution directory. But! Not because it actually uses the values inside it. What matters is that the keys your DbContext expects to find are present. If your context is wired up with a named connection string (e.g. UseSqlServer("name=ConnectionStrings:Default")), the bundle throws if that key is missing from the config file entirely even though you're overriding the actual value with --connection at runtime. The value itself can be complete garbage:

{
  "ConnectionStrings": {
    "Default": "this-value-is-never-actually-used"
  }
}
Enter fullscreen mode Exit fullscreen mode

So in practice: ship a near-empty appsettings.json with the structure your DbContext's configuration expects, fake values and all, and always pass the real connection string via --connection when you execute the bundle.

There's a second, blunter failure mode worth knowing: if appsettings.json isn't there at all, the bundle doesn't get anywhere near "missing key" territory! It fails outright while trying to construct the DbContext, and says so plainly:

Unable to create a 'DbContext' of type 'Contoso.Modules.Billing.Infrastructure.BillingDbContext'. The
exception 'The configuration file 'appsettings.json' was not found and is not optional. The expected
physical path was 'C:\Work\Contoso\artifacts\migration-bundles\appsettings.json'.' was thrown while
attempting to create an instance. For the different patterns supported at design time, see
https://go.microsoft.com/fwlink/?linkid=851728
Press any key to continue . . .
Enter fullscreen mode Exit fullscreen mode

Unlike the red-herring "no migrations applied" output above, this one is a genuine, unambiguous failure. It's the clearest confirmation that the file's presence is a hard requirement, independent of whether anything inside it is actually used.

Idempotency

Running a bundle against an already-current database is completely safe! It applies nothing and exits cleanly. Running it twice, ten times, or as part of every single deployment doesn't break anything; it only ever applies migrations that haven't been applied yet. That's what makes it safe to wire into a deploy step that runs unconditionally on every release, rather than something you have to remember to skip.

Automating Bundle Creation and Execution

Once you have more than one or two contexts, typing out the full dotnet ef migrations bundle command with the right --context, -r, and output path every time gets old fast, even copy-pasting it gets tiring after a while. Thats why I suggest using two .bat files, and they pay for themselves almost immediately. These are lightly genericized versions of what I'm actually running against a multi-context modular setup.

build-migration-bundles.bat: one build per context, self-contained, targeting win-x64:

@echo off
setlocal
REM ============================================================================
REM build-migration-bundles.bat
REM
REM Builds all four EF Core migration bundles (win-x64, self-contained) into
REM artifacts\migration-bundles\. Run from anywhere - it resolves paths
REM relative to the repo root automatically.
REM ============================================================================
cd /d "%~dp0"
REM adjust the line below if this .bat lives somewhere other than the repo root
set "REPO_ROOT=%cd%"
set "OUT=%REPO_ROOT%\artifacts\migration-bundles"
if not exist "%OUT%" mkdir "%OUT%"

echo ============================================================
echo  Building migration bundles (win-x64, Release)
echo ============================================================
echo.

echo [1/4] ExampleServiceDbContext...
dotnet ef migrations bundle ^
  --project "%REPO_ROOT%\src\Contoso.ExampleService.Api\Contoso.ExampleService.Api.csproj" ^
  --context ExampleServiceDbContext --self-contained -r win-x64 --configuration Release ^
  -o "%OUT%\migrate-auth.exe" --force
if errorlevel 1 goto :error

echo.
echo [2/4] OrdersDbContext...
dotnet ef migrations bundle ^
  --project "%REPO_ROOT%\src\BuildingBlocks\Platform.Modules\Platform.Modules.csproj" ^
  --startup-project "%REPO_ROOT%\src\Contoso.Platform.Api\Contoso.Platform.Api.csproj" ^
  --context OrdersDbContext --self-contained -r win-x64 --configuration Release ^
  -o "%OUT%\migrate-orders.exe" --force
if errorlevel 1 goto :error

echo.
echo [3/4] BillingDbContext...
dotnet ef migrations bundle ^
  --project "%REPO_ROOT%\src\BuildingBlocks\Platform.Modules\Platform.Modules.csproj" ^
  --startup-project "%REPO_ROOT%\src\Contoso.Platform.Api\Contoso.Platform.Api.csproj" ^
  --context BillingDbContext --self-contained -r win-x64 --configuration Release ^
  -o "%OUT%\migrate-billing.exe" --force
if errorlevel 1 goto :error

echo.
echo [4/4] CommonDataDbContext...
dotnet ef migrations bundle ^
  --project "%REPO_ROOT%\src\BuildingBlocks\Platform.Modules\Platform.Modules.csproj" ^
  --startup-project "%REPO_ROOT%\src\Contoso.Platform.Api\Contoso.Platform.Api.csproj" ^
  --context CommonDataDbContext --self-contained -r win-x64 --configuration Release ^
  -o "%OUT%\migrate-common.exe" --force
if errorlevel 1 goto :error

echo.
echo ============================================================
echo  All 4 bundles built successfully in %OUT%
echo ============================================================
pause
exit /b 0

:error
echo.
echo ============================================================
echo  BUILD FAILED - see error above.
echo ============================================================
pause
exit /b 1
Enter fullscreen mode Exit fullscreen mode

Anatomy of the Build Command

Taking the ExampleServiceDbContext line as the example:

dotnet ef migrations bundle ^
  --project "%REPO_ROOT%\src\Contoso.AuthService.Api\Contoso.AuthService.Api.csproj" ^
  --context ExampleServiceDbContext --self-contained -r win-x64 --configuration Release ^
  -o "%OUT%\migrate-auth.exe" --force
Enter fullscreen mode Exit fullscreen mode
  • --project: the .csproj that contains the DbContext and its Migrations folder. In a script that can be invoked from anywhere (not just the project directory), pass this explicitly rather than relying on EF's default of "whatever .csproj is in the current folder".
  • --startup-project: a separate flag for the project that acts as the composition root (where the DbContext actually gets registered in DI and its configuration resolved), when that's a different project than the one holding the migrations. This shows up the moment you go modular: the Orders, Billing, and CommonData contexts all live in a shared modules library (--project), but none of them are runnable on their own and they need the actual API host project (--startup-project) to build a service provider. ExampleServiceDbContext skips this flag entirely because its context lives directly inside its own API project. Project and startup project are one and the same, so there's nothing to disambiguate.
  • --context : which DbContext this specific bundle is for (mandatory once a project has more than one).
  • --self-contained: bakes the matching .NET runtime into the executable itself, so the target machine needs nothing pre-installed. The tradeoff is size: a self-contained bundle is tens of megabytes larger than a framework-dependent one. Worth it for a deploy target you don't fully control or don't want to maintain a runtime version on.
  • -r <RID> : the runtime identifier, and the one flag that matters most for CI/CD. This has to match where the bundle will actually execute, not where it's built. A win-x64 bundle will not run on a Linux CI runner or inside a Linux container and most default CI/CD images are Linux (GitHub Actions' ubuntu-latest, GitLab's default runners, Azure DevOps' Linux agent pools). If your pipeline builds and runs the bundle on a Linux agent, you need -r linux-x64 (or linux-musl-x64 specifically for Alpine-based images) regardless of what OS your production database server runs on, the RID describes the machine executing the bundle, not the database. If build and execution happen on different OSes in your pipeline (build on Linux, deploy/run on a Windows box), build for the target the run step will actually use.
  • --configuration Release : builds with Release settings rather than the Debug default, keeping it consistent with the rest of your build pipeline.
  • -o : output path and filename for the resulting executable.
  • --force : overwrites an existing file at the output path instead of erroring because it already exists. Without it, the second and every subsequent run of a CI build step fails on a stale artifact from the previous run, --force is effectively mandatory for anything that isn't a one-off manual build.

A few more flags that don't show up above but are worth knowing: --verbose for full diagnostic output when a build is failing and you can't tell why; --no-build to skip rebuilding the project first if you've already built it in an earlier pipeline step (saves time in CI); and --framework if the project multi-targets and you need to pick a specific target framework moniker.

run-all-migrations.bat : runs all four sequentially, tracks failures, and skips gracefully if a bundle wasn't built:

@echo off
setlocal enabledelayedexpansion
REM ============================================================================
REM run-all-migrations.bat
REM
REM Runs all four EF Core migration bundles sequentially against their target
REM database(s). Safe to run repeatedly - each bundle only applies migrations
REM that haven't already been applied (idempotent no-op if already up to date).
REM
REM Edit the connection strings below to match your target environment.
REM ============================================================================

REM ExampleServiceDbContext has its own database (matches src\Contoso.ExampleService.Api\appsettings.json).
set "EXAMPLE_CONNECTION=Host=example-db-server;Database=Example;..."
REM OrdersDbContext, BillingDbContext and CommonDataDbContext share one Platform
REM database (each uses its own schema + migration-history table), matches
REM src\Contoso.Platform.Api\appsettings.json.
set "PLATFORM_CONNECTION=Host=platform-db-server;Database=Platform;..."

cd /d "%~dp0"
set "FAILED_COUNT=0"

echo ============================================================
echo  Contoso - Running all migration bundles
echo ============================================================
echo.

call :run-bundle "migrate-auth.exe" "ExampleServiceDbContext" "%EXAMPLE_CONNECTION%"
call :run-bundle "migrate-orders.exe" "OrdersDbContext" "%PLATFORM_CONNECTION%"
call :run-bundle "migrate-billing.exe" "BillingDbContext" "%PLATFORM_CONNECTION%"
call :run-bundle "migrate-common.exe" "CommonDataDbContext" "%PLATFORM_CONNECTION%"

echo ============================================================
if %FAILED_COUNT% EQU 0 (
    echo  All migration bundles completed successfully.
) else (
    echo  %FAILED_COUNT% bundle^(s^) FAILED. Scroll up to review the errors.
)
echo ============================================================
echo.
pause
exit /b %FAILED_COUNT%

:run-bundle
set "BUNDLE_EXE=%~1"
set "CONTEXT_NAME=%~2"
set "CONNECTION=%~3"
echo ------------------------------------------------------------
echo  %CONTEXT_NAME%  (%BUNDLE_EXE%)
echo ------------------------------------------------------------
if not exist "%BUNDLE_EXE%" (
    echo   [SKIPPED] %BUNDLE_EXE% not found in this folder.
    echo.
    exit /b 0
)
"%BUNDLE_EXE%" --connection "%CONNECTION%"
if errorlevel 1 (
    echo   [FAILED] %CONTEXT_NAME% - see output above.
    set /a FAILED_COUNT+=1
) else (
    echo   [OK] %CONTEXT_NAME% up to date.
)
echo.
exit /b 0
Enter fullscreen mode Exit fullscreen mode

A couple of details in this one worth calling out beyond the obvious "different contexts, different connection strings": the :run-bundle subroutine skips cleanly (rather than crashing the whole script) if a given .exe isn't present, and it tracks a running FAILED_COUNT so the script's own exit code reflects whether anything failed. Which matters the moment this gets called from a CI step that checks the exit code rather than a human reading the console.

Don't skip the pause at the end of either script! Without it the window closes before you get a chance to read whether anything actually happened (see the sections above on misleading red text and hard failures).

Proof It Actually Works

To check that everything is correctly applied after running a bundle, I ran dotnet ef database update from the project directory, pointing it at the same context, project, and startup project:

dotnet ef database update `
  --project src\BuildingBlocks\Platform.Modules\Platform.Modules.csproj `
  --startup-project src\Contoso.Platform.Api\Contoso.Platform.Api.csproj `
  --context CommonDataDbContext
Build started...
Build succeeded.
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
No migrations were applied. The database is already up to date.
Done.
Enter fullscreen mode Exit fullscreen mode

Same --project/--startup-project/--context flags as the bundle build, they're general dotnet-ef flags, not something specific to bundling. And the result is the confirmation: the bundle already applied everything there was to apply, so the CLI tool finds nothing left to do and reports the database as current.

Bundles vs. the Alternatives

EF Core gives you three real ways to get migrations onto a database. Here's how they stack up:

Runtime (MigrateAsync) SQL Script Migration Bundle
Safe with multiple app instances Only on EF Core 9+ (via locking) Yes (manual process) Yes
Needs elevated DB permissions on the app itself Yes No No (only the bundle run needs them)
Inspectable before it runs No Yes, fully Only the source migrations, not the compiled artifact
Requires SDK/source on target machine Yes (it's the app) No No
Effort to set up Lowest Highest (manual DBA step) Low once scripted

Runtime migration is the simplest but carries every risk above. SQL scripts give a DBA full visibility and control but add a manual step. Bundles sit in between: no source code or dev tooling required on the target, still a single deliberate execution, still inspectable before you ship it, and they slot in cleanly as a first-class step in a deploy pipeline.

Where They Actually Fit

CI/CD and production pipelines are the first things that come to mind. A bundle is a natural artifact to produce in a build pipeline and hand off to a deploy step, and it deals cleanly with stale schemas without needing the app itself to have schema-altering permissions.

Key Takeaways

  • Bundles trade the simplicity of runtime migration for a single, auditable, out-of-process execution. It's worth it the moment more than one instance or more than one service touches the database.
  • One bundle per DbContext. Plan scripts around this from the start.
  • A red "no migrations applied" log line is not a failure. Trust the final Done.
  • appsettings.json next to the bundle needs the right keys, not real values. --connection overrides the actual connection string. If the file's missing entirely, though, that's a hard failure, so make sure yours is structured correctly too.
  • In CI/CD, -r has to match the OS actually running the bundle, not your production server. Most default pipeline runners are Linux, so build linux-x64 unless you know the execution step happens on Windows. And use --force, or your second pipeline run fails on a stale artifact.
  • They're idempotent, so wiring them into every deploy unconditionally is safe.
  • For test fixtures with a disposable container per run, Migrate()/EnsureCreated() beats a bundle since the app's already in-process. Save the bundle itself for one dedicated CI step that validates the exact artifact you're about to ship.

Documentation:
https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying?tabs=dotnet-core-cli
https://github.com/dotnet/efcore/issues/27685

Top comments (0)