Migrations
Create, review and ship EF Core migrations as SQL scripts or bundles applied by the deploy pipeline, not by the app at startup.
dotnet ef migrations add AddOrderStatus
dotnet ef migrations script --idempotent -o migrate.sql
dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundleA migration is a C# class that describes the schema change between two versions of your model. dotnet ef migrations add AddOrderStatus compares the current model with the last model snapshot and writes three files: 20261015093000_AddOrderStatus.cs with Up and Down, its .Designer.cs with the target model, and an updated ShopDbModelSnapshot.cs. The database records applied migrations in __EFMigrationsHistory; applying means running every migration whose id is not in that table, in id order.
The tool and design time
dotnet ef is a .NET tool (install it in a local tool manifest so CI pins the version) and needs Microsoft.EntityFrameworkCore.Design in the startup project. To get a context it builds your project and runs Program.cs up to builder.Build(), so anything there that connects to a broker or calls a remote API also runs on a developer's machine and in CI. If that is a problem, add an IDesignTimeDbContextFactory<T>; the tool uses it instead of the host. Bundles build their context the same way, so once a factory exists the bundle reads only what the factory reads. Have it read the same key as the app, ConnectionStrings:Shop, from environment variables, so ConnectionStrings__Shop configures the bundle with or without a factory.
Review every migration
Scaffolding is a diff, not intent. Renaming a property may be scaffolded as drop column plus add column, which deletes the data; EF prints An operation was scaffolded that may result in the loss of data when it sees a drop. Read Up, replace drops with RenameColumn where you meant a rename, add data backfills with migrationBuilder.Sql, and keep the migration and the code change in the same pull request. Never edit a migration that has been applied anywhere shared; add a new one. dotnet ef migrations has-pending-model-changes fails a CI build when someone changed the model without adding a migration.
Applying: four options
| Option | How | Verdict |
|---|---|---|
db.Database.MigrateAsync() at startup | Every replica tries on boot | Local development and tests only |
| SQL script | migrations script last-applied | Reviewable by a DBA; must know the current version |
| Idempotent script | migrations script --idempotent | Safe against any database version; good default for pipelines |
| Bundle | migrations bundle, then run efbundle | One executable, no SDK on the runner; good for a Kubernetes Job |
Why not at startup in production
Migrating from the app couples deployment to boot: three replicas start at once and queue on EF's database-wide migration lock, so every pod's startup waits on the slowest migration; a ten-minute index build blows through the startup probe and Kubernetes restarts the pod mid-migration; the app's database user needs DDL rights it should never have; and a failed migration leaves pods in a crash loop instead of failing one pipeline step. Run migrations as a separate deploy step with a privileged account, before the new version rolls out, and make each migration backwards-compatible with the version still running (add a nullable column now, make it required in a later release). Database Migrations in Deployments covers the migrator Job, its image and that expand-and-contract sequence.
Example
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
// Migrations/20261015093000_SplitCustomerName.cs, after review.
// Scaffolded as: AddColumn FirstName, AddColumn LastName, DropColumn FullName.
// Edited to keep the data and to stay compatible with the release still running.
[DbContext(typeof(ShopDb))]
[Migration("20261015093000_SplitCustomerName")]
public partial class SplitCustomerName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FirstName",
table: "Customers",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastName",
table: "Customers",
type: "character varying(100)",
maxLength: 100,
nullable: true);
// Backfill from the old column. FullName is dropped in a later release,
// once no running version reads it. One UPDATE locks every row it touches
// until the migration commits: fine for thousands of rows; on millions,
// backfill in batches outside the migration.
migrationBuilder.Sql("""
UPDATE "Customers"
SET "FirstName" = split_part("FullName", ' ', 1),
"LastName" = nullif(substr("FullName", length(split_part("FullName", ' ', 1)) + 2), '')
WHERE "FirstName" IS NULL;
""");
// A plain CREATE INDEX blocks all writes to Customers while it builds. On a large
// table add .Annotation("Npgsql:CreatedConcurrently", true) to get
// CREATE INDEX CONCURRENTLY, which runs outside the migration transaction.
migrationBuilder.CreateIndex(
name: "IX_Customers_LastName_FirstName",
table: "Customers",
columns: ["LastName", "FirstName"]);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "IX_Customers_LastName_FirstName", table: "Customers");
migrationBuilder.DropColumn(name: "FirstName", table: "Customers");
migrationBuilder.DropColumn(name: "LastName", table: "Customers");
}
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}
public sealed class Customer
{
public int Id { get; set; }
public required string FullName { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
}When to use it
- Ship an idempotent migrate.sql as a release artefact that a DBA reviews and applies before the API rollout
- Run efbundle as a Kubernetes Job with a DDL-capable account, then deploy pods whose user can only read and write rows
- Fail a pull request in CI with has-pending-model-changes when an entity changed but no migration was added
More examples
The everyday commands
<code>--project</code> is where the migrations live and <code>--startup-project</code> is the app the tool runs to build the context. The idempotent script wraps each migration in a check against <code>__EFMigrationsHistory</code>, so it can run against a database at any version. The bundle is a single executable containing the migrations, so the deploy runner needs neither the SDK nor the source. It builds its context the same way the tool does, so with no <code>--connection</code> it uses <code>ConnectionStrings__Shop</code>: through the app's configuration, or through a design-time factory that reads the same key.
# Once per repository: pin the tool version in .config/dotnet-tools.json
dotnet new tool-manifest
dotnet tool install dotnet-ef --version 10.0.13
# Create, inspect and undo (remove only works for the last, unapplied migration)
dotnet ef migrations add AddOrderStatus --project src/Shop.Data --startup-project src/Shop.Api
dotnet ef migrations list --project src/Shop.Data --startup-project src/Shop.Api
dotnet ef migrations remove --project src/Shop.Data --startup-project src/Shop.Api
# Local database only
dotnet ef database update --project src/Shop.Data --startup-project src/Shop.Api
# CI: fail the build if the model changed without a migration
dotnet ef migrations has-pending-model-changes --project src/Shop.Data --startup-project src/Shop.Api
# Release artefacts
dotnet ef migrations script --idempotent -o artifacts/migrate.sql --project src/Shop.Data --startup-project src/Shop.Api
dotnet ef migrations bundle --self-contained -r linux-x64 -o artifacts/efbundle --project src/Shop.Data --startup-project src/Shop.Api
# Deploy step, with a privileged account the app itself never gets. The bundle reads
# ConnectionStrings__Shop from the environment (set by the secret store), whether the context
# comes from the host or from the design-time factory;
# --connection "..." would put the password in ps output and CI logs.
./artifacts/efbundleA design-time factory
<code>migrations add</code>, <code>script</code>, <code>bundle</code> and <code>has-pending-model-changes</code> compare the model with the snapshot and never connect, so the connection string only has to be well-formed for them; <code>database update</code> needs a real database. Reading <code>ConnectionStrings:Shop</code> keeps one convention: a bundle run with <code>ConnectionStrings__Shop</code> set connects to that database, not to the localhost fallback. The provider options must match the runtime registration exactly, including a custom history table name, or the tool and the app disagree about what has been applied.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
// Used by dotnet ef and by bundles instead of running Program.cs. Keeps migrations working
// when startup needs secrets, a message broker or a cloud API that a laptop or CI cannot reach.
public sealed class ShopDbDesignTimeFactory : IDesignTimeDbContextFactory<ShopDb>
{
public ShopDb CreateDbContext(string[] args)
{
// The same key the app uses, so ConnectionStrings__Shop works with or without this factory.
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
// The fallback only serves commands that never connect (migrations add, script, bundle).
var connectionString = configuration.GetConnectionString("Shop")
?? "Host=localhost;Database=shop_design;Username=postgres;Password=<local-dev-only>";
var options = new DbContextOptionsBuilder<ShopDb>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__ef_migrations_history", "public"))
.Options;
return new ShopDb(options);
}
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
public sealed class Product
{
public int Id { get; set; }
public required string Sku { get; set; }
}What the idempotent script contains
Every statement is guarded by a lookup in <code>__EFMigrationsHistory</code>, so the same file is safe against a database at any version and a re-run after a partial failure skips what already committed. Review this file, not the C#, when a DBA signs off: it is exactly what runs. Running it, or the bundle, as a Kubernetes Job before the rollout is covered in Database Migrations in Deployments.
-- dotnet ef migrations script --idempotent (Npgsql), abridged to one migration.
START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261015093000_SplitCustomerName') THEN
ALTER TABLE "Customers" ADD "FirstName" character varying(100);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261015093000_SplitCustomerName') THEN
ALTER TABLE "Customers" ADD "LastName" character varying(100);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261015093000_SplitCustomerName') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20261015093000_SplitCustomerName', '10.0.13');
END IF;
END $EF$;
COMMIT;
Discussion