Migrations & Schema Evolution
A schema is never finished. Migrations are how its history stays reproducible instead of becoming tribal knowledge.
Intermediate
ALTER TABLE statements against production, unrecorded) is how schemas drift out of sync with what's in source control, and why a fresh developer's local database never quite matches staging.ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users RENAME COLUMN uname TO username;
ALTER TABLE users DROP COLUMN legacy_flag;
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
| Tool | Ecosystem |
|---|---|
| Alembic | Python, usually paired with SQLAlchemy |
| Flyway | JVM-ecosystem-agnostic, plain versioned .sql files |
| golang-migrate | Go, also usable standalone via CLI regardless of language |
| Django migrations / Rails Active Record migrations | built into those specific frameworks |
schema_migrations table), apply pending ones in order, and provide a rollback path. Pick whatever matches your application stack rather than fighting it.| Risky | Safer alternative |
|---|---|
Adding a column with a non-null DEFAULT on an old Postgres version | in PostgreSQL 11+, this is actually fast (metadata-only) — but a CHECK or NOT NULL added afterward with VALIDATE still needs care |
ADD COLUMN ... NOT NULL without a default, on a non-empty table | this doesn't run slowly — it fails outright ("column contains null values"). Add the column nullable, backfill in batches, then promote it: ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID, VALIDATE CONSTRAINT (which takes only a weak lock), then SET NOT NULL, which reuses the validated check and skips the table scan on PostgreSQL 12+. The CHECK can then be dropped |
| Renaming a column the application still queries by old name | expand/contract: add the new column, dual-write from the app, backfill, switch reads, then drop the old column in a later deploy |
Adding an index with default CREATE INDEX | CREATE INDEX CONCURRENTLY — slower, but doesn't hold a write lock on the table for the duration |
CREATE INDEX CONCURRENTLY in more depth.