what a migration actually is

A migration is a small, ordered, version-controlled script that transforms the schema from one known state to the next — and, ideally, an equally well-defined way to undo it. The alternative (hand-running 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.

common ALTER TABLE patterns

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);
Each of these is fast and safe on a small table. On a large table under real production traffic, several of them are not — see the zero-downtime section below.

migration tools

ToolEcosystem
AlembicPython, usually paired with SQLAlchemy
FlywayJVM-ecosystem-agnostic, plain versioned .sql files
golang-migrateGo, also usable standalone via CLI regardless of language
Django migrations / Rails Active Record migrationsbuilt into those specific frameworks
They all solve the same three problems: track which migrations have already run (usually a schema_migrations table), apply pending ones in order, and provide a rollback path. Pick whatever matches your application stack rather than fighting it.

zero-downtime migration practices

On a table with real production traffic, some seemingly simple changes lock the table or table long enough to matter:
RiskySafer alternative
Adding a column with a non-null DEFAULT on an old Postgres versionin 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 tableadd nullable first, backfill in batches, then add the constraint with NOT VALID + VALIDATE CONSTRAINT separately
Renaming a column the application still queries by old nameexpand/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 INDEXCREATE INDEX CONCURRENTLY — slower, but doesn't hold a write lock on the table for the duration
The common thread: separate a migration that changes the shape of data from one that changes a constraint, and never require the application code and the schema to update in the exact same instant — deploys and migrations don't happen atomically together.

where to go from here

Indexing StrategiesCREATE INDEX CONCURRENTLY in more depth.
Locking & Concurrency — exactly which DDL statements take which locks.