Learnixo
Back to blog
Backend Systemsadvanced

Zero-Downtime Database Migrations in .NET

Run database migrations safely in production .NET systems: the expand/contract pattern, backward-compatible migrations, EF Core migration bundles, blue-green deploys, and rollback strategies.

Asma Hafeez KhanMay 24, 20267 min read
.NETC#EF CoremigrationsDevOpsPostgreSQLproduction
Share:𝕏

Zero-Downtime Database Migrations in .NET

Running dotnet ef database update in production during a deploy is a reliability risk. This guide covers safe migration patterns for systems that must stay up.


Why Naive Migrations Break Production

Naive approach:
  1. Deploy new code
  2. Run migrations

Problems:
  - Old pods (still running during rolling deploy) read new schema → crash
  - Rename column → old code references old name → data loss or errors
  - Add NOT NULL column without default → existing rows violate constraint → migration fails
  - Lock entire table for ALTER → queries queue up → timeout cascade

The Expand / Contract Pattern

The safest migration strategy. Never make a breaking change in one step — always three phases.

Phase 1 — EXPAND (backward compatible):
  - Add new column as nullable (or with default)
  - Add new table
  - Old code ignores new column, new code writes both old and new

Phase 2 — MIGRATE DATA:
  - Backfill new column from old column
  - Can run as a background job or as part of the migration

Phase 3 — CONTRACT (remove old):
  - Deploy code that only uses the new column
  - Drop old column once all pods use new code
  - Old code no longer running → safe to drop
Example: rename Orders.DeliveryAddress → Orders.ShippingAddress

❌ BAD (one step, breaks rolling deploy):
  ALTER TABLE Orders RENAME COLUMN DeliveryAddress TO ShippingAddress;

✓ GOOD (three steps across three deploys):

  Deploy 1 (Expand):
    ALTER TABLE Orders ADD COLUMN ShippingAddress TEXT;
    -- New code writes both DeliveryAddress AND ShippingAddress
    -- Old code still reads DeliveryAddress → no breakage

  Deploy 2 (Migrate):
    UPDATE Orders SET ShippingAddress = DeliveryAddress WHERE ShippingAddress IS NULL;
    -- All rows now have ShippingAddress populated
    -- New code reads ShippingAddress, writes only ShippingAddress
    -- Old code still reads DeliveryAddress → still fine

  Deploy 3 (Contract):
    ALTER TABLE Orders DROP COLUMN DeliveryAddress;
    -- Old code is gone → safe to drop

EF Core Migration: Backward-Compatible Column Add

C#
// Migration: add nullable column with default — safe under rolling deploy
public partial class AddShippingAddress : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Step 1: add nullable — existing rows get NULL, no table lock on PostgreSQL
        migrationBuilder.AddColumn<string>(
            name:      "ShippingAddress",
            table:     "Orders",
            nullable:  true,
            defaultValue: null);

        // Step 2: backfill in the same migration (or separate migration for large tables)
        migrationBuilder.Sql("""
            UPDATE "Orders"
            SET "ShippingAddress" = "DeliveryAddress"
            WHERE "ShippingAddress" IS NULL
            """);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "ShippingAddress", table: "Orders");
    }
}
C#
// Application code during the transition period: write both columns
public class Order
{
    public string? DeliveryAddress  { get; set; }   // old — will be dropped later
    public string? ShippingAddress  { get; set; }   // new
}

// In the command handler:
order.DeliveryAddress = command.Address;   // keep old for backward compat
order.ShippingAddress = command.Address;   // populate new

Large Table Migrations (Avoiding Locks)

SQL
-- PostgreSQL: adding a NOT NULL column with a default locks the whole table
-- Bad for tables with millions of rows

-- SAFE approach for large tables:
-- 1. Add nullable
ALTER TABLE Orders ADD COLUMN IsArchived BOOLEAN;

-- 2. Backfill in batches (no full table lock)
DO $$
DECLARE
  batch_size INT := 10000;
  last_id    INT := 0;
BEGIN
  LOOP
    UPDATE Orders
    SET IsArchived = FALSE
    WHERE Id > last_id AND IsArchived IS NULL
    LIMIT batch_size
    RETURNING Id INTO last_id;

    EXIT WHEN NOT FOUND;
    PERFORM pg_sleep(0.01);  -- small pause to reduce I/O pressure
  END LOOP;
END;
$$;

-- 3. Set NOT NULL once all rows are backfilled (cheap  just validates constraint)
ALTER TABLE Orders ALTER COLUMN IsArchived SET NOT NULL;
ALTER TABLE Orders ALTER COLUMN IsArchived SET DEFAULT FALSE;
C#
// In EF Core migrations — split across two migrations
// Migration 1: add nullable, backfill
migrationBuilder.AddColumn<bool>("IsArchived", "Orders", nullable: true);
migrationBuilder.Sql("UPDATE \"Orders\" SET \"IsArchived\" = false WHERE \"IsArchived\" IS NULL");

// Migration 2 (separate deploy): make NOT NULL
migrationBuilder.AlterColumn<bool>("IsArchived", "Orders", nullable: false, defaultValue: false);

EF Core Migration Bundles

Migration bundles are self-contained executables — run migrations without the EF Core CLI installed on the server.

Bash
# Build a migration bundle
dotnet ef migrations bundle \
  --project src/Infrastructure \
  --startup-project src/Api \
  --output ./migrations-bundle \
  --self-contained

# Run in a Kubernetes init container or CI/CD step
./migrations-bundle --connection "Host=prod-db;Database=app;Username=app;Password=***"
YAML
# Kubernetes: run migration bundle as an init container
spec:
  initContainers:
  - name: db-migrate
    image: myapp-migrations:latest   # image containing the bundle
    command: ["./migrations-bundle"]
    env:
    - name: ConnectionStrings__Default
      valueFrom:
        secretKeyRef:
          name: db-secrets
          key: connection-string
  containers:
  - name: api
    image: myapp-api:latest
    # API starts only after migrations complete

Running Migrations on Startup (Development Only)

C#
// Program.cs — ONLY for development environments
if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

// In production: use migration bundle in CI/CD or init container
// Never call MigrateAsync() in production app startup:
//   - Multiple pods start simultaneously → race condition
//   - App fails to start if migration fails → complete outage

Rollback Strategy

EF Core does not support automatic rollback of data migrations.
Schema changes (ADD COLUMN, ADD TABLE) can be rolled back.
Data migrations (UPDATE, backfill) cannot — data is already mutated.

Safe rollback checklist:
  ✓ The Down() method of every migration must be correct and tested
  ✓ Phase 1 (expand) migrations are always safe to roll back (just drop the new column)
  ✓ Phase 3 (contract) migrations are NOT safe to roll back once data is dropped
  ✓ Before Phase 3: take a DB backup

Script to generate rollback SQL:
  dotnet ef migrations script   \
    --idempotent \
    --output rollback.sql
C#
// Mark a migration as applied without running it (emergency workaround)
// Use when you've manually applied SQL and EF Core doesn't know about it
dotnet ef database update --target-migration <MigrationName>

// List all applied migrations
dotnet ef migrations list

Index Creation Without Locking (PostgreSQL)

C#
// Creating an index locks the table in PostgreSQL — use CONCURRENTLY
// EF Core does not support CONCURRENTLY natively — use raw SQL

migrationBuilder.Sql(
    "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_id ON \"Orders\" (\"CustomerId\")",
    suppressTransaction: true);   // CONCURRENTLY cannot run inside a transaction

CI/CD Migration Pipeline

Recommended pipeline:

  PR merge to main:
    1. Build migration bundle
    2. Run bundle against a staging DB (validates migration succeeds)
    3. Run application tests against migrated staging DB

  Production deploy:
    1. Run migration bundle against prod DB (init container or deploy job)
    2. Wait for migrations to complete
    3. Deploy new application pods (rolling update)

  Rollback:
    1. Stop rolling deploy
    2. Run rollback.sql generated by 'ef migrations script'
    3. Redeploy old application version

  Never:
    - Run migrations from application startup in production
    - Run Phase 3 (DROP) migrations without a backup
    - Rename columns in a single migration

Interview Answer

"Zero-downtime migrations require the expand/contract pattern across three deploys: first expand (add new column as nullable), then backfill data, then contract (drop old column) after all code uses the new one. Renaming a column in one step breaks a rolling deploy because old pods still reference the old name. For large tables, batched backfills avoid table locks — add nullable, update in chunks of 10,000, then set NOT NULL separately. EF Core migration bundles compile migrations into a self-contained executable — run as a Kubernetes init container before new pods start, so the database is always ready before the app boots. Never call MigrateAsync on production startup: multiple pods racing causes failures. Index creation on live tables must use CREATE INDEX CONCURRENTLY with suppressTransaction: true in EF Core. Always generate rollback SQL with 'ef migrations script' before any Phase 3 drop migration, and take a backup first."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.