Covers dotnet ef migrations in CI/CD: --project/--startup-project, design-time factories, idempotent SQL scripts, migration bundles, pending model change checks, and why startup migration is risky.
I generate migrations locally with dotnet ef migrations add, then make deployment a separate, reviewed step. The pipeline produces an artefact: either an idempotent SQL script (dotnet ef migrations script --idempotent) for a DBA to review, or a migration bundle (dotnet ef migrations bundle), a self-contained executable that applies pending migrations. That artefact runs once, before the new app version starts, with a dedicated deployment identity. I avoid Database.Migrate() at startup because several instances can race, the app needs DDL permissions it shouldn't have, and a failed migration turns into a crash loop.
"I generate migrations locally with
dotnet ef migrations add, using a pinned localdotnet-ef, the Design package on the startup project, explicit--projectand--startup-project, and anIDesignTimeDbContextFactoryso the tool doesn't boot the whole app. I review every migration and its SQL like code. Then the build job produces an artefact: either an idempotent script for a DBA to review, or a migration bundle built self-contained for the agent's platform, which runs once as a pre-deploy step under a dedicated deployment identity, with the connection string passed in from the secret store.For example, our [payments API] ran
Database.Migrate()on startup across [six] instances, and a release added an index to a [40-million-row] table during [business hours]. I needed to unblock the rollout and stop schema changes ever coupling to app start again. I traced the stall toCREATE INDEXlocks, halted the deployment, then moved us to a bundle built in CI and run before the slot swap under its own identity. I cut the app's database rights to read and write only, and added ahas-pending-model-changesgate to the build. Deployments became [predictable], with [no] further migration-related incidents in [the following months].That's why I avoid migrating at startup: instances queue on the lock, the app needs DDL rights it shouldn't have, and a failed migration becomes a crash loop. The mistake I see is dropping a column in the same release that stops using it, so I use expand and contract, confirm a backup first, and roll forward rather than trusting
Down(). Schema change and application start are separate concerns, each with its own identity and approval."
dotnet-ef is the CLI tool, best installed as a pinned local tool.Microsoft.EntityFrameworkCore.Design must be referenced by the startup project, otherwise dotnet ef cannot run.--project points at the project that contains the DbContext and migrations; --startup-project points at the project the tool builds and runs to discover configuration and services.IDesignTimeDbContextFactory<T> lets the tool create the context without booting the whole app host, which is useful when the startup needs secrets or services that aren't available at build time.dotnet ef migrations add AddCustomerAbn \
--project src/Billing.Data --startup-project src/Billing.Api
Review the generated migration like any other code. Watch for renames that EF scaffolded as drop-and-add (data loss), and for long-running operations on big tables.
| Option | Command | Good for |
|---|---|---|
| Idempotent SQL script | dotnet ef migrations script --idempotent -o migrate.sql |
DBA review, change-controlled environments |
| Migration bundle | dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle |
Pipelines that should not need the SDK or source at deploy time |
dotnet ef database update |
Directly against a database | Local development only |
Database.Migrate() at startup |
In app code | Small single-instance apps, dev and tests |
An idempotent script wraps every migration in a check against __EFMigrationsHistory, so it is safe to run against a database at any earlier version. A bundle does the same job as an executable, and takes --connection at run time so the connection string comes from the pipeline's secret store.
Migrate(), but you still pay the startup cost on every instance and must think about it.ALTER/CREATE rights. In production the app identity should usually be limited to data access, and only the deployment identity should run DDL.A payments API in Azure Australia East ran Migrate() in Program.cs. A migration that added an index to a 40-million-row table locked writes for minutes while every new pod waited on it, and the rollout stalled. We moved to a migration bundle built in CI, run as a pre-deployment job under a separate managed identity, and split large index changes into online operations scheduled out of hours.
dotnet ef migrations has-pending-model-changes in CI so a model change without a migration fails the build.Probes production maturity with EF Core and pipelines.
Green flags: knows --idempotent and bundles; argues least privilege and concurrency against startup migration; mentions backward-compatible (expand/contract) changes and a pending-changes check.
Red flags: "we just run database update" against prod; migrations from developer machines; no idea what __EFMigrationsHistory is.
| Piece | Where it lives | Why it matters |
|---|---|---|
dotnet-ef tool |
Local tool manifest (.config/dotnet-tools.json) |
The CLI front end. Pin it to the same major and minor version as your EF Core packages |
Microsoft.EntityFrameworkCore.Design |
Referenced by the startup project (usually with PrivateAssets="all") |
Contains the design-time services the tool loads. Without it you get "Your startup project doesn't reference Microsoft.EntityFrameworkCore.Design" |
Migrations project (--project) |
The class library that holds the DbContext and the Migrations/ folder |
Where new migration files are written and read from |
Startup project (--startup-project) |
The executable project, such as the Web API | The tool builds it and loads its configuration and services to create the context |
| Provider package | For example Microsoft.EntityFrameworkCore.SqlServer or Npgsql.EntityFrameworkCore.PostgreSQL |
Generates provider-specific SQL |
dotnet ef migrations add AddCustomerAbn --project Data --startup-project Api
|
v
1. dotnet build Api (which builds Data)
|
v
2. Load Api's assembly plus Microsoft.EntityFrameworkCore.Design
|
v
3. Create the DbContext, trying in this order:
a) An IDesignTimeDbContextFactory<T> in the startup or migrations
project --> use it
b) The app's host (WebApplication.CreateBuilder / CreateHostBuilder)
--> run Program up to building the host, resolve T from DI
c) A parameterless constructor on T --> new it up
|
v
4. Compare the current model with the model snapshot
(Migrations/<Context>ModelSnapshot.cs)
|
v
5. Write <timestamp>_AddCustomerAbn.cs, its .Designer.cs,
and an updated snapshot
Step 3b is why design-time commands can fail in CI: building the host runs your Program.cs, which may try to read Key Vault, connect to a message broker or validate options. A design-time factory (3a) avoids all of that and gives the tool only what it needs.
If you must keep 3b, you can detect design time in Program.cs with EF.IsDesignTime (EF Core 9 and later) and skip startup work such as seeding or calling external services.
| File | Contains | Edit by hand? |
|---|---|---|
20261022010203_AddCustomerAbn.cs |
Up() and Down() methods with operations such as AddColumn, CreateIndex, Sql(...) |
Yes, when needed, for example to add a data backfill with migrationBuilder.Sql(...) |
20261022010203_AddCustomerAbn.Designer.cs |
The model as it was at that migration, plus the [Migration] id attribute |
No |
BillingContextModelSnapshot.cs |
The current model, used to compute the next diff | No. Use dotnet ef migrations remove to undo the last unapplied migration |
The database records applied migrations in the __EFMigrationsHistory table (columns MigrationId and ProductVersion). That table, not the files, is what decides which migrations still need to run.
| Option | Command | Who runs it | Needs SDK and source at deploy time? | Good for |
|---|---|---|---|---|
| Direct update | dotnet ef database update |
A developer | Yes | Local development only |
| Idempotent SQL script | dotnet ef migrations script --idempotent -o migrate.sql |
DBA or pipeline via sqlcmd / Invoke-Sqlcmd |
No | Change-controlled environments where people review SQL |
| Migration bundle | dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle |
Pipeline deploy job | No | Automated pipelines; one executable per release |
| Startup migration | context.Database.Migrate() in Program.cs |
Every app instance on boot | No | Small single-instance apps, local dev, integration tests |
Without --idempotent, the script assumes the database is at a specific starting migration (by default, empty). With it, every migration is wrapped in a check against the history table, for example on SQL Server:
IF NOT EXISTS (SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20261022010203_AddCustomerAbn')
BEGIN
ALTER TABLE [Customers] ADD [Abn] nvarchar(11) NULL;
END;
So the same script is safe against a database at any earlier version. You can also script a range: dotnet ef migrations script AddOrders AddCustomerAbn produces only the changes between those two migrations.
A bundle is a small executable that contains your compiled migrations, EF Core and the provider. It applies pending migrations, the same way database update would, but without the SDK, the source code or dotnet-ef on the deploy agent.
./efbundle --connection "$BILLING_DEPLOY_DB" # apply all pending
./efbundle 20261001000000_AddOrders --connection ... # migrate to a specific one
./efbundle --verbose --connection ... # print the SQL it runs
Build it for the deploy agent's platform (-r linux-x64 or -r win-x64) and with --self-contained if the agent has no .NET runtime.
6 pods start together
pod1 --Migrate()--> takes migration lock, runs CREATE INDEX (8 min)
pod2 --Migrate()--> waits for lock ...
pod3 --Migrate()--> waits for lock ...
...
readiness probes time out --> orchestrator restarts pods --> they wait again
| Problem | Detail |
|---|---|
| Concurrency | EF Core 9 and later take a database-level lock during Migrate(), so instances no longer corrupt each other, but every instance still blocks until the slowest migration finishes |
| Least privilege | The app identity needs ALTER and CREATE (often db_owner). If the app is compromised, so is the schema |
| Failure mode | A failing migration makes every instance fail to start, so a schema problem becomes an outage |
| No review gate | Nobody sees the SQL before it reaches production |
| Coupled rollback | Rolling the app back doesn't roll the schema back, and the old version may not work with the new schema |
Since EF Core 9, Migrate() also throws if the model has changes with no migration (the PendingModelChangesWarning is an error by default). That is helpful, but it means a forgotten migration is discovered at production startup instead of in CI.
BUILD (once) DEPLOY (per environment)
------------------------------- ----------------------------------
dotnet tool restore download artefacts
dotnet build -c Release approval gate (prod)
dotnet ef migrations ./efbundle --connection "$DB"
has-pending-model-changes <- gate (runs as deploy identity)
dotnet ef migrations bundle failed? stop here, app unchanged
dotnet ef migrations script deploy new app version
--idempotent (for review) smoke tests
publish app + efbundle + migrate.sql
Build the bundle once and promote the same file through test, staging and production. The connection string comes from the pipeline's secret store (an Azure Key Vault-linked variable group, GitHub environment secrets) at deploy time, never from the repo.
During a rolling deploy, old and new app versions run against the same database. Every migration must therefore work with the version currently running.
To rename Phone to MobileNumber with no downtime:
| Release | Schema change | App change |
|---|---|---|
| 1. Expand | Add MobileNumber (nullable). Backfill it in batches |
Write to both columns, read Phone |
| 2. Switch | None | Read and write MobileNumber |
| 3. Contract | Drop Phone |
None |
EF scaffolds a property rename as RenameColumn, and some renames (or type changes) as drop and add, which loses data. Read every generated migration before you commit it.
| Operation | Risk | Safer approach |
|---|---|---|
CreateIndex on a large table |
Blocks writes while it builds | On SQL Server Enterprise or Azure SQL, use migrationBuilder.Sql("CREATE INDEX ... WITH (ONLINE = ON)"); on PostgreSQL, CREATE INDEX CONCURRENTLY (outside a transaction) |
AlterColumn to non-nullable |
Fails if nulls exist, or rewrites the table | Backfill first, then alter in a later migration |
DropColumn / DropTable |
Data loss, breaks the running old version | Expand and contract; back up first |
Big data updates in Sql(...) |
Long transaction, log growth, lock escalation | Batch the update, or run it as a separate job |
| Changing a column type | Possible table rebuild and truncation | Add a new column, copy, switch, drop |
CREATE INDEX CONCURRENTLY on PostgreSQL). Use migrationBuilder.Sql("...", suppressTransaction: true) and plan for partial failure.Down() methods are rarely tested and often lose data.dotnet ef migrations script AddCustomerAbn AddOrders (from a later migration to an earlier one), and review it.| Error | Usual cause | Fix |
|---|---|---|
| "Unable to create a 'DbContext' of type ..." | The host failed to build at design time, or no constructor fits | Add an IDesignTimeDbContextFactory<T>, or run with --verbose to see the inner exception |
| "Your startup project ... doesn't reference Microsoft.EntityFrameworkCore.Design" | Missing package on the startup project | Add it to the startup project, not only the data project |
| "The EF Core tools version ... is older than that of the runtime" | dotnet-ef and EF packages differ |
Update the version in the tool manifest |
| "The model for context ... has pending changes" | Someone changed the model without a migration | Run dotnet ef migrations add, and add has-pending-model-changes to CI |
| Idempotent script fails to compile on SQL Server | A batch references a column created or dropped in the same script | Generate the script with a current EF version, which wraps such statements in EXEC, or split the migration |
Add --verbose to any dotnet ef command to see the build output, which context it picked and the full stack trace.
db_datareader and db_datawriter, or specific grants).EF Core 11 is due with .NET 11 in November 2026, as a standard-term (STS) release, and is at the preview and release-candidate stage now. Expect the same dotnet ef commands and the same pipeline shape. Check the EF Core 11 "what's new" and breaking-changes pages before upgrading, and keep dotnet-ef in step with the packages. Production systems should stay on EF Core 10 (LTS) until you have tested the upgrade.
Api) and a class library (Data) with a DbContext that uses SQLite. Reference Microsoft.EntityFrameworkCore.Design from Api.dotnet new tool-manifest and dotnet tool install dotnet-ef so the tool is pinned locally.dotnet ef migrations add Init --project Data --startup-project Api and open the three generated files.dotnet ef migrations script --idempotent -o migrate.sql and find the IF NOT EXISTS (or SQLite equivalent) checks.dotnet ef migrations bundle --project Data --startup-project Api -o efbundle, then run ./efbundle --connection "Data Source=app.db" twice. The second run applies nothing.dotnet ef migrations has-pending-model-changes. Note the non-zero exit code that would fail a CI step.RenameColumn or a drop and add.namespace Billing.Data;
// Used only by dotnet ef at design time; never by the running app.
public sealed class BillingContextFactory : IDesignTimeDbContextFactory<BillingContext>
{
public BillingContext CreateDbContext(string[] args)
{
var connection = Environment.GetEnvironmentVariable("BILLING_DESIGN_DB")
?? "Server=(localdb)\\mssqllocaldb;Database=BillingDesign;Trusted_Connection=True";
var options = new DbContextOptionsBuilder<BillingContext>()
.UseSqlServer(connection)
.Options;
return new BillingContext(options);
}
}
# Azure Pipelines (excerpt)
- script: dotnet tool restore
- script: dotnet ef migrations has-pending-model-changes --project src/Billing.Data --startup-project src/Billing.Api
displayName: Fail if the model has no matching migration
- script: >
dotnet ef migrations bundle
--project src/Billing.Data --startup-project src/Billing.Api
--configuration Release --self-contained -r linux-x64
-o $(Build.ArtifactStagingDirectory)/efbundle
displayName: Build migration bundle
# Deploy stage, running under the deployment service connection
- script: ./efbundle --connection "$(BillingDbDeployConnection)"
displayName: Apply migrations before swapping slots
Mistake: calling Database.Migrate() at startup in a multi-instance service.
Every instance blocks on the migration lock until the slowest migration finishes, readiness probes time out, and a failed migration stops every instance from starting. The app identity also needs DDL rights it shouldn't have.
Instead: build a migration bundle or idempotent script in CI and run it once, as a separate pre-deployment step, under a dedicated deployment identity.
Mistake: running dotnet ef database update from a laptop against production.
There is no review, no audit trail, and the result depends on whatever code and tool version happen to be on that laptop.
Instead: apply migrations only from the pipeline, using the artefact built from the reviewed commit.
Mistake: applying a migration to production first. Run time, locking and data problems (nulls in a column you're making required, a huge table) only show up against realistic data. Instead: run the same bundle or script against a staging database restored from a recent, appropriately masked production copy, and time it.
Mistake: referencing Microsoft.EntityFrameworkCore.Design only from the data project, or mixing up --project and --startup-project.
The tool loads design-time services from the startup project, and writes migrations to the migrations project. Getting these wrong produces confusing errors or migrations in the wrong folder.
Instead: reference the Design package from the startup project, and always pass both options explicitly in scripts and pipelines.
Mistake: committing real connection strings in a design-time factory. The factory lives in source control, so the secret is shared with everyone who can read the repo, and stays in its history. Instead: read the design-time connection string from an environment variable or user secrets, and pass the deployment connection string to the bundle from the pipeline's secret store or use a managed identity.
Mistake: generating scripts without --idempotent.
A plain script assumes the database is at a known starting migration. Run against a database at a different version, it fails or applies changes twice.
Instead: use dotnet ef migrations script --idempotent for any environment whose version you don't control exactly.
Mistake: dropping a column in the same release that stops using it. During a rolling deploy the old version is still running and still queries that column, and an app rollback no longer works. Instead: use expand and contract: add the new shape, switch the app, then drop the old column in a later release.
Mistake: committing generated migrations without reading them.
EF can scaffold a rename as a drop and add, which deletes data, or create an index that locks a large table for minutes.
Instead: review every migration and its SQL (dotnet ef migrations script <previous> <new>) like any other code change, with a checklist for table size, locking and data loss.
Mistake: relying on Down() methods to undo a bad production migration.
Down() is rarely tested, often loses data, and doesn't help if the app has already written data in the new shape.
Instead: roll forward with a new corrective migration, and confirm a backup or point-in-time restore before destructive releases.
Mistake: letting the dotnet-ef tool version drift from the EF Core packages.
A mismatched tool warns or fails, and can generate code for a different EF version.
Instead: pin dotnet-ef in the local tool manifest and update it in the same pull request that updates the EF packages.
Mistake: no CI check for missing migrations.
A model change without a migration is found at deploy time, or (since EF Core 9) as an exception when Migrate() runs.
Instead: run dotnet ef migrations has-pending-model-changes in the build job so the pipeline fails early.
The senior move is to separate schema change from application start. Migrations become an artefact with its own identity, approval and failure handling, and the app only ever needs data permissions. Choose script versus bundle based on your organisation: a bank with a DBA change board wants reviewable SQL; a product team with automated environments prefers bundles.
Migration history and failure recovery are part of the design:
__EFMigrationsHistory is the source of truth for what has been applied. Never edit or delete a migration that has reached a shared environment; add a new one instead.Down methods in production. If you need a reverse script, generate it explicitly with dotnet ef migrations script <From> <To> and review it.Under the Australian Privacy Act, a destructive migration that silently drops personal data you were required to keep, or keeps data you were meant to delete, is a compliance problem as well as a technical one.
A .NET tool is a console app shipped as a special NuGet package. Global tools (dotnet tool install -g) are installed per user and land on your PATH. Local tools are pinned per repository in a manifest, .config/dotnet-tools.json, created with dotnet new tool-manifest. Anyone who clones the repo runs dotnet tool restore and gets exactly the same versions. For team and CI work I prefer local tools, because the version lives in source control next to the code that depends on it.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
The pipeline restores three times, builds in Debug, runs dotnet format in a way that silently fixes files instead of failing, and publishes from a different build than the one it tested. I'd use global.json for the SDK, restore once with --locked-mode, build once in Release with --no-restore, then test and publish with --no-build. I'd run dotnet format --verify-no-changes so formatting drift fails the build, collect test results and coverage, and cache the NuGet folder.
How to tell it (STAR)
**Situation:** Our payments API at [company] ran Database.Migrate() on startup across [six] instances, and a release added an index to a [40-million-row] table during [business hours]. **Task:** I needed to unblock the rollout and stop schema changes from ever coupling to app start again. **Action:** I traced the stall to CREATE INDEX locks via [sys.dm_exec_requests], halted the deployment, then moved us to an EF migration bundle built in CI and run as a pre-deploy step under a dedicated identity. I also cut the app's database rights to read/write only and added a has-pending-model-changes gate. **Result:** Deployments went from [unpredictable] to [a steady N minutes], we had no further migration-related incidents in [period], and the security review signed off on least-privilege database access.
Last updated 1 Nov 2026.