LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Database Migrations Without Downtime: Expand/Contract, Shadow Tables, and Safe Deploys

databasespostgresqlmigrationsdevopszero-downtimebackendci-cdschema

Database migrations are the leading cause of unplanned downtime in web applications. Not because they’re inherently dangerous, but because most teams don’t have a systematic approach to making them safe. They write a migration, run it in staging, it works fine, they run it in production, and it locks the table for 45 seconds while 50,000 users experience timeouts.

The good news: running schema changes on a live production database without downtime is entirely possible for nearly every operation. The approach is counter-intuitive — it requires splitting what feels like a single change into multiple coordinated steps across multiple deployments — but once you internalize the pattern, it becomes second nature.

This guide covers the core mental model (expand/contract), the specific patterns for every common migration type, how database locks work and which operations take them, and the tooling that automates the dangerous parts.


Why Migrations Cause Downtime

Before patterns, the mechanism. When PostgreSQL (or MySQL) runs a DDL statement like ALTER TABLE, it acquires a lock on the table. Different operations require different lock levels:

PostgreSQL lock levels (most to least restrictive):

Lock Level Conflicts With Common Operations
AccessExclusiveLock Everything ALTER TABLE (many forms), DROP TABLE, TRUNCATE
ShareUpdateExclusiveLock Writes, other schema changes CREATE INDEX CONCURRENTLY, VACUUM, ANALYZE
ShareLock Writes CREATE INDEX (non-concurrent)
RowExclusiveLock Other writes INSERT, UPDATE, DELETE
AccessShareLock Nothing practical SELECT

An AccessExclusiveLock blocks all reads and writes — every query queues behind it. On a busy table, even a one-second migration can cause a cascade: queries queue up, the queue grows, connection pool exhausts, application returns 500s.

The situation is worse than it appears because of lock queuing. If a long-running SELECT holds an AccessShareLock and your migration is waiting for AccessExclusiveLock, every subsequent query also waits — behind the migration. A 2-second migration can cause a 30-second outage if there’s a slow ongoing query.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- See what locks are currently held and waiting
SELECT
  pid,
  now() - pg_stat_activity.query_start AS duration,
  query,
  state,
  wait_event_type,
  wait_event
FROM pg_stat_activity
WHERE state != 'idle'
  AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY duration DESC;

-- See lock conflicts explicitly
SELECT
  blocked.pid,
  blocked_activity.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking_activity.query AS blocking_query
FROM pg_locks blocked
JOIN pg_stat_activity blocked_activity ON blocked.pid = blocked_activity.pid
JOIN pg_locks blocking
  ON blocking.relation = blocked.relation AND blocking.granted AND NOT blocked.granted
JOIN pg_stat_activity blocking_activity ON blocking.pid = blocking_activity.pid;

The Expand/Contract Pattern

The foundational pattern for zero-downtime migrations is expand/contract (also called parallel change). Instead of making a breaking change in a single deployment, you make it in three phases:

Phase 1: EXPAND   — Add the new thing, keep the old thing working
Phase 2: MIGRATE  — Move data, update code to use the new thing
Phase 3: CONTRACT — Remove the old thing once fully migrated

Each phase is a separate deployment. Between phases, both old and new work simultaneously. The application is always compatible with the current database state.

Let’s walk through every common migration type.


Renaming a Column

This is the canonical example. You cannot simply rename a column — any running instance of the old code will fail to find the renamed column.

The Wrong Way

1
2
3
-- DO NOT DO THIS on a live database
ALTER TABLE users RENAME COLUMN name TO full_name;
-- Old code: SELECT name FROM users → ERROR: column "name" does not exist

The Right Way

Phase 1: EXPAND — Add the new column, dual-write from application

1
2
-- Migration: add new column (fast, no table rewrite)
ALTER TABLE users ADD COLUMN full_name TEXT;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Application code after phase 1: write to BOTH columns
def create_user(full_name: str) -> User:
    return db.execute(
        "INSERT INTO users (name, full_name) VALUES (%s, %s) RETURNING *",
        (full_name, full_name)
    ).fetchone()

def update_user_name(user_id: int, full_name: str):
    db.execute(
        "UPDATE users SET name = %s, full_name = %s WHERE id = %s",
        (full_name, full_name, user_id)
    )

def get_user(user_id: int) -> User:
    # Read from old column — new column may not be populated for old rows yet
    return db.execute("SELECT *, COALESCE(full_name, name) AS full_name FROM users WHERE id = %s", (user_id,)).fetchone()

Phase 2: MIGRATE — Backfill existing rows, switch reads to new column

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- Backfill in batches (never one giant UPDATE on a large table)
DO $$
DECLARE
  batch_size INT := 1000;
  last_id BIGINT := 0;
  max_id BIGINT;
BEGIN
  SELECT MAX(id) INTO max_id FROM users;
  WHILE last_id < max_id LOOP
    UPDATE users
    SET full_name = name
    WHERE id > last_id
      AND id <= last_id + batch_size
      AND full_name IS NULL;

    last_id := last_id + batch_size;
    PERFORM pg_sleep(0.01);  -- brief pause to avoid I/O saturation
  END LOOP;
END $$;
1
2
3
4
# Application code after backfill: read from new column
def get_user(user_id: int) -> User:
    return db.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
    # full_name is now the authoritative column

Phase 3: CONTRACT — Remove old column (after confirming no code references it)

1
2
-- Only after verifying all application instances use full_name
ALTER TABLE users DROP COLUMN name;
1
2
3
4
5
6
# Application code: write only to new column
def create_user(full_name: str) -> User:
    return db.execute(
        "INSERT INTO users (full_name) VALUES (%s) RETURNING *",
        (full_name,)
    ).fetchone()

Adding a NOT NULL Column

Adding a column with NOT NULL and no default in PostgreSQL pre-11 required a full table rewrite. In PostgreSQL 11+, adding a column with a constant default is instant. But nullable → not-null transitions still need care.

Instant in PostgreSQL 11+

1
2
3
4
-- PostgreSQL 11+: adding a column with a constant default is instant
-- (stored as a table-level default, not written to each row)
ALTER TABLE orders ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';
-- No table rewrite — immediate ✓

Safe NOT NULL for Non-Constant Defaults

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
-- Step 1: Add as nullable (instant)
ALTER TABLE events ADD COLUMN processed_at TIMESTAMPTZ;

-- Step 2: Backfill existing rows (batched)
UPDATE events SET processed_at = created_at WHERE processed_at IS NULL;

-- Step 3: Add NOT NULL constraint with a validation check
-- (validates without a full lock in PostgreSQL 12+)
ALTER TABLE events
  ADD CONSTRAINT events_processed_at_not_null
  CHECK (processed_at IS NOT NULL)
  NOT VALID;  -- marks existing rows as already validated (trusted)

-- Step 4: Validate the constraint (uses ShareUpdateExclusiveLock — allows reads/writes)
ALTER TABLE events VALIDATE CONSTRAINT events_processed_at_not_null;

-- Step 5: Now you can safely add NOT NULL (uses the validated constraint)
-- In a later migration after step 4 is deployed and running:
ALTER TABLE events ALTER COLUMN processed_at SET NOT NULL;
ALTER TABLE events DROP CONSTRAINT events_processed_at_not_null;

The NOT VALID / VALIDATE CONSTRAINT split is PostgreSQL’s mechanism for adding constraints without locking — VALIDATE CONSTRAINT uses ShareUpdateExclusiveLock (blocks DDL, allows reads and writes).


Adding Indexes Without Locking

CREATE INDEX without CONCURRENTLY takes a ShareLock — it blocks all writes for the duration (minutes on large tables). Always use CONCURRENTLY:

1
2
3
4
5
6
-- Blocks writes for potentially minutes on large tables — NEVER in prod
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- CONCURRENTLY: takes ShareUpdateExclusiveLock, allows reads AND writes
-- Takes 2-3x longer, but zero downtime
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

CONCURRENTLY caveats:

  • Cannot run inside a transaction block
  • If it fails partway through, leaves an INVALID index that you must DROP INDEX CONCURRENTLY
  • Takes roughly 2-3x longer than regular index creation
1
2
3
4
5
6
7
8
9
-- Check for invalid indexes (left behind by failed CONCURRENTLY)
SELECT indexrelid::regclass AS index_name
FROM pg_index
WHERE NOT indisvalid;

-- Clean up an invalid index
DROP INDEX CONCURRENTLY idx_orders_user_id;
-- Then recreate
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

Setting a lock_timeout as a Safety Net

Even operations that should be fast can block unexpectedly if a long-running query holds a conflicting lock. Set lock_timeout in your migration runner so the migration fails fast rather than queueing up and cascading:

1
2
3
4
-- Set a lock timeout — fail if we can't acquire the lock within 5 seconds
SET lock_timeout = '5s';
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ;
-- If this hangs, it fails after 5s rather than blocking all queries
1
2
3
4
5
# In your migration framework:
def run_migration_safely(conn, sql: str, lock_timeout: str = "5s"):
    conn.execute(f"SET lock_timeout = '{lock_timeout}'")
    conn.execute(f"SET statement_timeout = '60s'")
    conn.execute(sql)

Changing a Column’s Type

Type changes are among the most dangerous migrations because PostgreSQL must rewrite every row.

Safe Type Widening (Often Instant)

1
2
3
4
5
6
7
8
-- integer → bigint: instant in PostgreSQL (widens in place)
ALTER TABLE events ALTER COLUMN user_id TYPE BIGINT;

-- varchar(50) → varchar(255): instant (just changes metadata)
ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(255);

-- varchar(255) → text: instant
ALTER TABLE users ALTER COLUMN description TYPE TEXT;

Unsafe Type Narrowing (Always Requires a Rewrite Strategy)

1
2
-- text → integer: requires data validation and a full table rewrite
-- DO NOT run directly — use the expand/contract approach

The safe way: expand/contract with a shadow column

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- Phase 1: Add new typed column
ALTER TABLE products ADD COLUMN price_cents INTEGER;

-- Phase 2: Backfill (validate data in application code first)
UPDATE products
SET price_cents = (price::NUMERIC * 100)::INTEGER
WHERE price_cents IS NULL;

-- Phase 3: Switch application writes, verify, then drop old column
ALTER TABLE products DROP COLUMN price;
ALTER TABLE products RENAME COLUMN price_cents TO price;

Removing a Column

Removing a column is safe at the database level (it’s instant), but dangerous at the application level — any code that references the old column will break.

1
2
3
4
-- Safe timing: only after ALL running application instances
-- have been deployed with code that no longer references this column

ALTER TABLE users DROP COLUMN legacy_token;

The expand/contract sequence:

  1. Deploy code that no longer reads or writes legacy_token
  2. Verify no application process still selects that column
  3. Run the DROP COLUMN migration

For Rails, Django, Hibernate, and other ORMs that auto-select * columns, explicitly ignore the column before dropping it:

1
2
3
4
5
6
7
# Django: ignore a column before dropping it
class User(models.Model):
    # Mark as deferred — won't be included in default SELECT *
    legacy_token = models.TextField(null=True, db_column='legacy_token')

    class Meta:
        managed = False  # or use a custom migration
1
2
3
4
5
# Rails: use ignored_columns before dropping
class User < ApplicationRecord
  self.ignored_columns = ["legacy_token"]
end
# Deploy this, then run the DROP COLUMN migration

Large Table Backfills

Backfilling a column on a table with 100M rows cannot be done in a single UPDATE — it will hold locks and block for minutes, generate excessive WAL, and consume the entire I/O budget.

Batched Backfill Pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- Batched backfill with pauses — run this as a background job, not a migration
DO $$
DECLARE
  batch_size  INT     := 5000;
  updated     INT     := 0;
  total       INT     := 0;
  last_id     BIGINT  := 0;
BEGIN
  LOOP
    UPDATE orders
    SET    tax_amount = subtotal * 0.1
    WHERE  id > last_id
      AND  id <= last_id + batch_size
      AND  tax_amount IS NULL;

    GET DIAGNOSTICS updated = ROW_COUNT;
    EXIT WHEN updated = 0;

    total   := total + updated;
    last_id := last_id + batch_size;

    RAISE NOTICE 'Backfilled % rows (total: %)', updated, total;
    PERFORM pg_sleep(0.05);  -- 50ms pause between batches
  END LOOP;
END $$;

Using a Background Job

For very large tables, run the backfill as an application-level background job rather than inside the migration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# A Celery / RQ / Sidekiq job that runs the backfill incrementally
def backfill_tax_amount(batch_size: int = 1000, max_batches: int = 100):
    """Run as a recurring job until backfill completes."""
    with db.transaction():
        rows = db.execute("""
            UPDATE orders
            SET tax_amount = subtotal * 0.1
            WHERE id IN (
                SELECT id FROM orders
                WHERE tax_amount IS NULL
                LIMIT %s
                FOR UPDATE SKIP LOCKED   ← avoids deadlocks with concurrent runs
            )
            RETURNING id
        """, (batch_size,)).fetchall()

    if not rows:
        logger.info("Backfill complete")
        return

    logger.info(f"Backfilled {len(rows)} rows")
    # Reschedule if there may be more
    backfill_tax_amount.apply_async(countdown=1)  # 1 second delay

FOR UPDATE SKIP LOCKED is important — it prevents two concurrent backfill workers from deadlocking on the same rows.


Splitting and Merging Tables (Shadow Table Pattern)

For structural changes — splitting one table into two, changing primary keys, migrating from UUID to BIGINT, or fundamentally reorganizing a schema — the shadow table pattern provides a clean path.

The Pattern

  1. Create the new table structure
  2. Sync data from old to new via triggers
  3. Backfill historical data
  4. Switch application reads to new table
  5. Switch application writes to new table
  6. Remove triggers and old table
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
-- Phase 1: Create new structure
CREATE TABLE user_profiles (
  id         BIGSERIAL PRIMARY KEY,
  user_id    BIGINT NOT NULL REFERENCES users(id) UNIQUE,
  bio        TEXT,
  avatar_url TEXT,
  location   TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Phase 2: Create sync trigger (new writes to users also write to user_profiles)
CREATE OR REPLACE FUNCTION sync_user_to_profile()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    INSERT INTO user_profiles (user_id, bio, avatar_url, location)
    VALUES (NEW.id, NEW.bio, NEW.avatar_url, NEW.location)
    ON CONFLICT (user_id) DO UPDATE SET
      bio        = EXCLUDED.bio,
      avatar_url = EXCLUDED.avatar_url,
      location   = EXCLUDED.location,
      updated_at = NOW();
  ELSIF TG_OP = 'UPDATE' THEN
    UPDATE user_profiles SET
      bio        = NEW.bio,
      avatar_url = NEW.avatar_url,
      location   = NEW.location,
      updated_at = NOW()
    WHERE user_id = NEW.id;
  ELSIF TG_OP = 'DELETE' THEN
    DELETE FROM user_profiles WHERE user_id = OLD.id;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_user_profile_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_to_profile();

-- Phase 3: Backfill existing rows (batched)
INSERT INTO user_profiles (user_id, bio, avatar_url, location, created_at)
SELECT id, bio, avatar_url, location, created_at
FROM users
ON CONFLICT (user_id) DO NOTHING;

-- Phase 4 & 5: Application switches to reading/writing user_profiles
-- (Deploy new application code — the trigger keeps both in sync during cutover)

-- Phase 6: Remove trigger and old columns
DROP TRIGGER sync_user_profile_trigger ON users;
DROP FUNCTION sync_user_to_profile();
ALTER TABLE users DROP COLUMN bio;
ALTER TABLE users DROP COLUMN avatar_url;
ALTER TABLE users DROP COLUMN location;

Verifying Sync Consistency

Before removing the trigger, verify the tables are in sync:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- Check for rows in users that are missing from user_profiles
SELECT u.id
FROM users u
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE up.user_id IS NULL;
-- Should return 0 rows

-- Check for diverged data
SELECT u.id, u.bio, up.bio AS profile_bio
FROM users u
JOIN user_profiles up ON up.user_id = u.id
WHERE u.bio IS DISTINCT FROM up.bio
LIMIT 20;
-- Should return 0 rows

Migration Tooling

Flyway

Flyway uses versioned SQL files and a flyway_schema_history table to track which migrations have been applied. Simple, reliable, widely used in Java/JVM ecosystems but language-agnostic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- V1__Create_users.sql
CREATE TABLE users (
  id    BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  name  TEXT NOT NULL
);

-- V2__Add_full_name.sql
ALTER TABLE users ADD COLUMN full_name TEXT;
UPDATE users SET full_name = name;

-- V3__Drop_name.sql
ALTER TABLE users DROP COLUMN name;
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# docker-compose.yml — run Flyway before starting the app
services:
  migrate:
    image: flyway/flyway:10
    command: migrate
    environment:
      FLYWAY_URL: jdbc:postgresql://db:5432/app
      FLYWAY_USER: app
      FLYWAY_PASSWORD: secret
      FLYWAY_LOCATIONS: filesystem:/migrations
    volumes:
      - ./migrations:/migrations
    depends_on:
      db:
        condition: service_healthy

Liquibase

Liquibase supports SQL and XML/YAML/JSON changeset formats with rollback support:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# changelog.yaml
databaseChangeLog:
  - changeSet:
      id: 1
      author: jmoon
      changes:
        - addColumn:
            tableName: users
            columns:
              - column:
                  name: full_name
                  type: TEXT
      rollback:
        - dropColumn:
            tableName: users
            columnName: full_name

golang-migrate

Simple Go CLI and library for SQL migrations:

1
2
3
4
5
6
7
8
9
# Install
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# Create migration files
migrate create -ext sql -dir migrations -seq add_full_name_to_users

# Generates:
# migrations/000002_add_full_name_to_users.up.sql
# migrations/000002_add_full_name_to_users.down.sql
1
2
3
4
5
-- 000002_add_full_name_to_users.up.sql
ALTER TABLE users ADD COLUMN full_name TEXT;

-- 000002_add_full_name_to_users.down.sql
ALTER TABLE users DROP COLUMN full_name;
1
2
3
4
5
# Run migrations
migrate -path migrations -database "postgres://user:pass@localhost/app?sslmode=disable" up

# Roll back one step
migrate -path migrations -database "..." down 1

pgroll (Safe Migrations for PostgreSQL)

pgroll is a PostgreSQL-specific tool that automates the expand/contract pattern. It maintains old and new versions of the schema simultaneously during a migration, allowing zero-downtime deploys:

1
2
3
4
5
6
# pgroll migration definition
operations:
  - rename_column:
      table: users
      from: name
      to: full_name

pgroll generates the dual-schema automatically, handling the view and trigger plumbing that makes both old column names work simultaneously. When you’re confident, run pgroll complete to remove the old schema.

strong_migrations (Rails)

The strong_migrations gem adds compile-time safety checks to ActiveRecord migrations, catching dangerous patterns before they hit production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Gemfile
gem 'strong_migrations'

# A migration that would cause downtime is caught at development time:
class AddIndexToOrders < ActiveRecord::Migration[7.1]
  def change
    add_index :orders, :user_id  # ← strong_migrations raises:
    # StrongMigrations::UnsafeMigration:
    # Adding an index non-concurrently blocks reads and writes.
    # Use: add_index :orders, :user_id, algorithm: :concurrently
  end
end

django-pg-zero-downtime-migrations

For Django + PostgreSQL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django_pg_zero_downtime_migrations.backends.postgresql',
        ...
    }
}

# Now Django migrations automatically use CONCURRENTLY for indexes,
# NOT VALID for constraints, and other safe patterns

The CI/CD Integration

Migrations should run automatically but with safeguards:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# GitHub Actions: safe migration workflow
jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run migrations with timeout and rollback on failure
        env:
          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
        run: |
          # Dry-run first: validate migration SQL is syntactically correct
          migrate -path ./migrations -database "$DATABASE_URL" \
            -dry-run up 2>&1 | tee /tmp/migration-plan.txt

          echo "=== Migration plan ==="
          cat /tmp/migration-plan.txt

          # Apply with a timeout
          timeout 300 migrate -path ./migrations -database "$DATABASE_URL" up \
            || { echo "Migration failed or timed out"; exit 1; }

      - name: Verify schema version
        run: |
          # Confirm expected version is applied
          migrate -path ./migrations -database "$DATABASE_URL" version

The key principle: migrations run before new application code is deployed (not after). The sequence for zero-downtime:

1. Deploy migration (schema is backward-compatible — both old and new code work)
2. Deploy new application code
3. (Later) Deploy cleanup migration removing old columns/tables

This requires that every migration be backward-compatible until the cleanup step. If you DROP COLUMN in the same deploy as the code that stops using it, there’s a window where old pods are still running and hitting the now-absent column.


A Checklist for Every Migration

Before running any migration on production:

Design

  • Is the migration backward-compatible with the current version of application code?
  • Does it follow expand/contract if it’s a breaking change?
  • Is the backfill batched (not a single UPDATE on a large table)?

Lock safety

  • Have I verified the lock level this operation requires?
  • Did I use CREATE INDEX CONCURRENTLY instead of CREATE INDEX?
  • Did I use ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT for new constraints?
  • Is lock_timeout set in the migration to fail fast rather than cascade?

Execution

  • Has this run against a production-scale dataset in staging?
  • Is there a rollback plan (or is the forward path clear enough)?
  • Is the migration idempotent (safe to run twice)?
  • Is it running during low-traffic hours if it involves any risk?

Code

  • Is the application code already deployed that’s compatible with the new schema?
  • Is the cleanup migration scheduled (removal of old columns after the transition period)?

Database migrations done right aren’t scary — they’re just multiple small, safe steps instead of one large risky one. The overhead of thinking in phases is real, but it’s trivially less than the overhead of an outage.

Comments