To change your database schema without breaking production, never make a change that old code cannot survive. Split every risky change into small, backward-compatible steps (the expand and contract migration pattern): add the new thing, write to both, backfill in batches, move reads, then remove the old thing in a later deploy. On Postgres, set a short lock_timeout before every migration, build indexes with CREATE INDEX CONCURRENTLY, add constraints as NOT VALID and validate them separately, and time each migration on a copy of production-size data before you run it for real.
This guide is for solo makers and tiny teams running a live product on Postgres. The worked example is made up; the database behaviour comes from the PostgreSQL 18 docs and the tool docs linked below, all checked in October 2026.
Why do schema changes break a live product?
On your laptop, a migration runs against a tiny database with nothing else happening. In production, four things are true at once:
Old code is still running. During a deploy, the previous version keeps serving requests for seconds or minutes. If your migration renames
bookings.student_name, every old server that still selectsstudent_namestarts throwing errors the moment the migration commits.Some changes lock the table. Many
ALTER TABLEforms take the strongest lock Postgres has. While it is held, nothing else can read or write that table.Data changes take time. One
UPDATEacross a big table holds row locks until it finishes.Some steps cannot be undone. A dropped column takes its data with it. A down migration cannot bring that data back.
Zero-downtime database migrations mostly come down to one habit: asking what happens to the code and queries running while this executes.
What is the expand and contract migration pattern?
Martin Fowler's site describes parallel change, also known as expand and contract, as breaking a backward-incompatible change into three phases: expand, migrate and contract. The same article calls it a key part of evolutionary database design.
For a database it looks like this:
Expand. Add the new column, table or index next to the old one. Nothing reads it yet, so nothing can break.
Migrate. Make the code write to both shapes, copy old data across, then switch reads to the new shape. Old and new code can run side by side at every step.
Contract. Once nothing touches the old shape, remove it.
The cost is more deploys and a period where code handles two shapes. The article (by Danilo Sato) warns that skipping the contract phase can leave you worse off than before, so schedule the cleanup on day one.
How do you rename a column without downtime?
A rename is the classic trap, because RENAME COLUMN is instant in Postgres and still breaks your app. The data is fine; the old code is not. The strong_migrations README spells out the safer order: create a new column, write to both, backfill, move reads, stop writing to the old column, drop it.
Here is that order for renaming bookings.student_name to attendee_name, one deploy per step:
Deploy 1, expand: migration adds the column.
ALTER TABLE bookings ADD COLUMN attendee_name text;No default, no constraint, so it is a quick metadata change.Deploy 2, dual write: code writes the same value to both columns on every insert and update. Reads still use
student_name.Backfill: a script copies
student_nameintoattendee_namefor old rows, in small batches (more on that below).Deploy 3, move reads: code reads
attendee_name. Search for every use of the old name: raw SQL, exports, jobs.Deploy 4, stop old writes: code no longer touches
student_name. If your ORM caches the column list, tell it to ignore the old column first (strong_migrations shows this withignored_columnsin Rails).Deploy 5, contract: a migration drops
student_name, a few days later, after a backup.
Five deploys sounds like a lot, but on a small product the whole thing fits in a week.

Which Postgres schema changes are safe, and which lock the table?
The PostgreSQL ALTER TABLE docs say an ACCESS EXCLUSIVE lock is taken unless a subform says otherwise. The explicit locking chapter explains that this lock conflicts with every other lock mode, including plain reads. So the real question for Postgres schema changes is not "does it lock?" but "how long does it hold the lock, and does it scan or rewrite the table while holding it?"
Change | What Postgres does | Safer way |
|---|---|---|
Add a nullable column, no default | Metadata only, no rewrite | Fine as is (with a lock timeout) |
Add a column with a constant default | Default stored in metadata, no rewrite (since PostgreSQL 11) | Fine as is |
Add a column with a volatile default such as | Rewrites the whole table and its indexes | Add without default, backfill in batches, then set the default |
Create an index | Plain |
|
Make a column NOT NULL | Scans the whole table while blocking updates |
|
Add a foreign key | Scans to check existing rows, blocking updates on both tables | Add with |
Change a column's type | Usually rewrites the table and its indexes | Expand and contract: new column, backfill, switch, drop |
Rename a column or table | Instant, but old code breaks | Expand and contract (see above) |
Drop a column | Quick (column hidden, space reclaimed later), but data is gone | Stop using it in code first, back up, then drop |
A few details worth knowing from the docs:
Constant vs volatile defaults. The PostgreSQL 11 release notes made adding a column with a constant default fast. A volatile default, stored generated column or identity column still rewrites.
Concurrent indexes have rules. The CREATE INDEX docs say a concurrent build takes longer, cannot run inside a transaction block, and on failure leaves an
INVALIDindex you should drop and rebuild. Rails wraps each migration in a transaction by default, so check how your tool turns that off.NOT VALID then VALIDATE.
NOT VALIDskips the scan and commits at once; new rows are checked from then on. The laterVALIDATE CONSTRAINTtakes only aSHARE UPDATE EXCLUSIVElock, so reads and writes continue. PostgreSQL 18 also allowsNOT VALIDon not-null constraints; on older versions the CHECK route works becauseSET NOT NULLskips its scan when a valid CHECK constraint proves there are no nulls.
On SQLite or MySQL the lists differ, so read your own database's docs. Django's docs, for example, note that MySQL lacks transactions around schema changes, so a failed migration must be unpicked by hand.
Why can a fast ALTER TABLE still take your app down?
Your migration may need the lock for only a millisecond, but it has to get it first. If a slow report or a forgotten open transaction holds a lock on the table, your ALTER TABLE waits, and new queries line up behind it.
Squawk's guide to applying migrations safely describes exactly this: while the statement waits for its lock, all other reads and writes sent after it wait too, "bringing your application to a halt". A tiny change becomes an outage as long as the slowest query in front of it.
The fix is two settings from the Postgres client connection settings:
lock_timeoutaborts a statement that waits longer than the limit to get a lock. Default is 0, which means wait forever.statement_timeoutaborts any statement that runs longer than the limit. Also 0 (off) by default.
Put them at the top of each migration:
SET lock_timeout = '2s'; SET statement_timeout = '60s';
If the migration hits the lock timeout, it fails cleanly, waiting queries carry on, and you retry a minute later. Squawk's example uses 1 second; strong_migrations suggests 10 seconds for the lock and a long statement timeout. Raise statement_timeout on purpose for slow, non-blocking work like a concurrent index build. If you use a pooler such as PgBouncer in transaction mode, strong_migrations notes you must set these on the database user instead.
How do you backfill data without locking everything?
Do not run UPDATE bookings SET attendee_name = student_name; on a big production table. It touches every row in one transaction and cannot be paused.
Write a small script instead:
Update one batch by primary key range, a few thousand rows at a time:
UPDATE bookings SET attendee_name = student_name WHERE id BETWEEN 1 AND 5000 AND attendee_name IS NULL;Commit, sleep briefly, move on. Log the last id so you can resume.
Run it outside the schema migration. strong_migrations lists the three keys as batching, throttling and running outside a transaction.
Run it once more at the end to catch stragglers.
The attendee_name IS NULL condition makes it safe to rerun.
Should you run the migration before or after deploying the code?
Use one rule: the database must work with both the old and the new code at every moment.
Adding things: migrate first, then deploy code that uses them. Old code ignores a column it does not know about.
Removing things: deploy code that stops using them first, then migrate. New code does not miss a column it never asks for.
Never in one step: a migration that removes something and code that depends on the removal in the same deploy. That is the shape of most migration outages.
If you want to switch reads to the new column for a few users before everyone, a flag works well here; the MakerHunt guide to shipping risky changes behind feature flags covers the deploy versus release split, so I will not repeat it.
Where should migrations run: CI, deploy, or by hand?
Pick one migration tool for your stack and let it own the schema. Common ones: Rails Active Record migrations, Django migrations, Drizzle Kit (which can generate SQL migration files or push the schema directly), Prisma (which has its own expand and contract guide), Alembic for SQLAlchemy, Flyway and golang-migrate. The tool matters less than the rules around it:
Read the SQL before it runs. A one-line model change can become a table rewrite. Django's
sqlmigrateprints a migration's SQL; Drizzle Kit writes plain SQL files.Use versioned migration files in production, not schema "push" commands, which are for dev databases.
Lint migrations in CI. strong_migrations (Rails) blocks dangerous operations by default and explains the safer way. Squawk lints raw Postgres SQL and has rules for missing lock timeouts, non-concurrent indexes and constraints without
NOT VALID.Run migrations as one explicit deploy step, not on every app boot, where several servers can race to run the same migration.

Can you roll back a database migration?
Sometimes. Mostly the down migration is fiction: written in a hurry, never run, and unable to restore data the up migration deleted. Roll back a dropped column and you get an empty column.
Plan to roll forward: if a change goes wrong, ship a new migration that fixes it. Expand and contract helps, because until the contract step the old column still has its data and switching back is a code change.
Before any destructive step (drop, type change, data rewrite), take a fresh backup or snapshot and know how long a restore takes. A backup you have never restored is a hope; the DEV walkthrough of a monthly restore drill for a solo SaaS shows how to check yours.
How do you test a migration on production-size data?
A migration that is instant on seed data can crawl on a table with years of rows. Before anything risky:
Restore a recent backup into a separate database.
Run the migration there with
\timingon in psql and write the time down.While it runs, query
pg_locksorpg_stat_activityfrom a second session to see what it holds and what waits.Run one batch of the backfill and multiply out the total time.
That checks the migration only; for the rest of the app, see SideHunt's guide on how much testing a side project needs.
A pre-flight checklist and migration log
Before every migration that touches a table with real traffic, answer five questions:
Can the currently deployed code run against the new schema?
Does any statement rewrite or scan a large table while holding a strong lock?
Is
lock_timeoutset?Did I time it on a production-size copy?
If it removes anything, is there a fresh backup and is the code already off it?
Then keep a short log:
Field | Example entry |
|---|---|
Date and time | Tue 10:15, low-traffic hour |
Migration | Add |
Phase | Expand (1 of 5) |
Timed on copy | Under a second |
Locks and timeouts |
|
Backup before | Not needed (additive) |
Result | Ran first try |
Next step and date | Dual-write deploy Wednesday |
A made-up example: Cobblehive renames a column in five deploys
This example is invented to show the steps. Cobblehive and Halvard are not real.
Halvard runs Cobblehive, a class-booking app for small pottery studios, on one Postgres database. Many bookings were gift vouchers for someone else, so "student" was the wrong word. He wanted bookings.student_name to become attendee_name.
His first draft was a RENAME COLUMN plus a code change in one deploy, which would have broken every booking page on the old servers during rollout. So he split it:
Monday: added the nullable column with
lock_timeoutset. It failed once on a lock timeout because a nightly export was still running; he retried ten minutes later and it went through.Tuesday: shipped dual writes, then ran the backfill script in batches and logged the last id after each batch.
Wednesday: switched reads. A search for the old name found it in a CSV export and an email template he had forgotten.
Friday: stopped writing the old column.
The next week: took a backup, dropped
student_name, and wrote the log entry.
Nothing broke for users, and the forgotten export would have broken in the one-step version.
What mistakes cause the most migration outages?
Weak | Better |
|---|---|
Rename or drop a column in the same deploy as the code change | Expand and contract across several deploys |
No lock timeout, so the migration queues behind a slow query |
|
Plain |
|
| Add as |
One giant | Rerunnable script in small batches, outside the migration |
Trusting the down migration | Roll forward; fresh backup before anything destructive |
Timing the migration on seed data | Timing it on a production-size copy |
Never doing the contract step | Put the cleanup on the list with a date |
A 7-day plan to make your next schema change safe
Day counts are a planning guess for a small product; move faster or slower to fit.
Day | Do this |
|---|---|
1 | Add |
2 | Add a migration linter to CI (strong_migrations or Squawk) and fix or accept what it flags. |
3 | Restore a backup into a scratch database and time your last five migrations against it. |
4 | Write the backfill script template: batches, sleep, resume from last id, rerunnable. |
5 | Make migrations an explicit deploy step, not something that runs on app boot. |
6 | Start the migration log and add any half-finished expand steps you already have. |
7 | Run your next real change through the checklist, as expand and contract if it removes or renames anything. |
Database migration FAQ
Is ADD COLUMN safe in Postgres?
Usually. A nullable column or one with a constant default is a metadata change and does not rewrite the table. A volatile default, a stored generated column or an identity column does rewrite it. Either way, set a lock timeout, because even a quick change has to wait for its lock.
Do I need expand and contract for a brand-new table?
No. Creating a table nothing uses yet is additive and safe. The pattern matters when existing code depends on the shape you are changing.
Can I just take the app down for a minute instead?
For a tiny product, a short announced maintenance window is a fair choice for a large rewrite. Time it on a copy first, so "a minute" really is a minute.
Small steps, boring deploys
Database migration best practices come down to never asking old code to survive a change it cannot handle, and never holding a lock longer than you meant to. And when the thing you are building is ready for more makers to try, MakerHunt is a friendly place to launch it.