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 huge table | add nullable first, backfill in batches, then add the constraint with NOT VALID + VALIDATE CONSTRAINT separately |
| 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.