Skip to main content
Back to module
Database Administration · Security and Monitoring

Schema migrations

Reading41mLesson 3 of 3 in this module

What you'll learn

  • Plan a zero-downtime migration
  • Identify schema changes that lock, scan or rewrite a table
  • Apply expand, backfill, switch and contract across releases
  • Use lock_timeout, NOT VALID constraints and CREATE INDEX CONCURRENTLY

Plan schema changes as versioned migrations and roll them out without downtime using expand, backfill and contract.

Why it matters

Applications change, so schemas change: new columns, renamed fields, new indexes, split tables. On a small database you can change the schema and restart the app. On a busy production system, the wrong ALTER TABLE can lock a table for minutes, queue every request behind it and take the site down. Deploying new code while old code is still running adds a second problem: both versions must work against the same schema at the same time. Planning safe migrations is a core DBA skill.

Migrations as code

A migration is a small, versioned script that moves the schema from one version to the next. Tools record which migrations have run in a table in the database and apply the rest in order: Flyway and Liquibase (any stack), Alembic (Python), Rails and Django migrations, Prisma Migrate, Knex and others.

Good habits:

  • Every change is a migration in version control, reviewed like code. No hand edits in production.
  • Migrations are small and do one thing.
  • Each one is tested on a copy of production-sized data before release, so you know how long it takes and what it locks.
  • You know how to roll back, or how to roll forward with a fix if a rollback would lose data.

Why some changes hurt

In PostgreSQL most ALTER TABLE forms take an ACCESS EXCLUSIVE lock, which blocks reads and writes on that table. For many changes the lock is brief; the danger is changes that rewrite or scan the whole table while holding it, and the fact that a waiting ALTER also blocks every query queued behind it.

ChangeTypical impact (PostgreSQL 11+)
ADD COLUMN nullable, or with a constant defaultFast: catalog-only change
ADD COLUMN ... DEFAULT now() or other volatile defaultRewrites the table
ALTER COLUMN ... TYPE to an incompatible typeUsually rewrites the table
SET NOT NULLScans the table (skipped if a validated CHECK (col IS NOT NULL) exists, PG 12+)
ADD FOREIGN KEY / CHECKScans unless added NOT VALID, then VALIDATE CONSTRAINT later with a weaker lock
CREATE INDEXBlocks writes for the build; use CREATE INDEX CONCURRENTLY
Rename or drop a columnFast, but breaks any running code that still uses the old name

MySQL has its own rules (ALGORITHM=INSTANT/INPLACE, and tools such as gh-ost or pt-online-schema-change), so always check your engine's documentation.

Set a short lock_timeout in migrations (SET lock_timeout = '5s';) so a migration that cannot get its lock fails fast and can be retried, instead of freezing traffic.

Expand, migrate, contract

Zero-downtime changes are split across several deploys so that old and new code both work at every step. Renaming customers.fullname to customers.display_name:

  1. Expand: add the new column (nullable, fast). Deploy code that writes both columns and still reads the old one.
  2. Backfill: copy existing data in small batches to avoid long locks and huge transactions:
SQL
UPDATE customers SET display_name = fullname
WHERE id IN (
  SELECT id FROM customers
  WHERE display_name IS NULL AND fullname IS NOT NULL
  LIMIT 5000
);
-- repeat until 0 rows are updated
  1. Switch reads: deploy code that reads the new column. Add constraints safely:
SQL
ALTER TABLE customers
  ADD CONSTRAINT display_name_not_null CHECK (display_name IS NOT NULL) NOT VALID;
ALTER TABLE customers VALIDATE CONSTRAINT display_name_not_null;
ALTER TABLE customers ALTER COLUMN display_name SET NOT NULL;  -- uses the valid CHECK, no long scan
  1. Contract: once no running code uses the old column (check logs and every service), stop writing it, then drop it in a later release.

The same pattern handles splitting tables, changing types (new column, backfill, switch) and moving data between services.

Index changes

SQL
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id);

CONCURRENTLY cannot run inside a transaction block, and if it fails it leaves an INVALID index that must be dropped and retried. Most migration tools need a flag to run a migration outside their usual transaction.

A release checklist

  • Fresh backup (or a confirmed PITR window) before the change.
  • Migration timed on realistic data; locks understood.
  • lock_timeout and statement_timeout set.
  • Run at a low-traffic time, with someone watching locks and errors.
  • Rollback or roll-forward plan written down.

Common mistakes

  • Renaming or dropping a column in the same release as the code change.
  • One giant UPDATE backfill that bloats the table and holds locks for an hour.
  • Creating indexes without CONCURRENTLY on a busy table.
  • Testing migrations only on an empty development database.

Quick recap

  • Migrations are versioned, reviewed, small scripts run by a tool.
  • Know which changes lock, scan or rewrite; use lock_timeout, NOT VALID plus VALIDATE, and CREATE INDEX CONCURRENTLY.
  • Expand, backfill, switch, contract: old and new code must both work at every step.

Try it

In the Do tab, carry out a full expand/backfill/contract column rename on your practice server.

Key terms (6)

migration
A small, versioned script that moves the schema from one version to the next, applied in order by a tool.
ACCESS EXCLUSIVE
The PostgreSQL lock most ALTER TABLE forms take, which blocks all reads and writes on the table.
lock_timeout
A setting that makes a statement fail fast if it cannot get its lock, instead of freezing queued traffic.
Expand
The first step of a zero-downtime change: add the new structure while old code keeps working.
Backfill
Copying existing data into a new column or table in small batches to avoid long locks.
Contract
The final step: remove the old column or structure once no running code uses it.

Recap

  • Migrations are small, versioned, reviewed scripts run by a tool and tested on production-sized data.
  • Know which changes lock, scan or rewrite; set lock_timeout and use NOT VALID plus VALIDATE.
  • Build indexes with CREATE INDEX CONCURRENTLY, outside a transaction.
  • Expand, backfill in batches, switch reads, then contract: old and new code must work at every step.
  • Take a fresh backup and write a rollback or roll-forward plan first.
Also in this lesson: