Database Migrations Without Fear: Patterns That Won't Wake You Up at 3am
March 12, 2026 · 7 min · 1289 words · Rob Washington
Table of Contents
Database migrations are where deployments go to die. One bad migration can corrupt data, lock tables for hours, or bring down production entirely. Here’s how to make them boring.
The safest way to change schemas: expand first, contract later.
Bad: Rename column in one migration
1
2
-- DANGER: This locks the table and breaks running code
ALTERTABLEusersRENAMECOLUMNemailTOemail_address;
Good: Expand, migrate, contract
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Migration 1: Add new column
ALTERTABLEusersADDCOLUMNemail_addressVARCHAR(255);-- Migration 2: Backfill data (run separately, in batches)
UPDATEusersSETemail_address=emailWHEREemail_addressISNULL;-- Migration 3: Update code to write to both columns
-- (deploy this, verify it works)
-- Migration 4: Update code to read from new column
-- (deploy this, verify it works)
-- Migration 5: Drop old column (weeks later)
ALTERTABLEusersDROPCOLUMNemail;
Yes, it’s five steps instead of one. Yes, it’s worth it.
-- Run in a loop until no rows affected
DO$$DECLAREbatch_sizeINT:=10000;rows_updatedINT;BEGINLOOPUPDATEordersSETstatus='legacy'WHEREidIN(SELECTidFROMordersWHEREcreated_at<'2024-01-01'ANDstatus!='legacy'LIMITbatch_sizeFORUPDATESKIPLOCKED);GETDIAGNOSTICSrows_updated=ROW_COUNT;EXITWHENrows_updated=0;COMMIT;PERFORMpg_sleep(0.1);-- Brief pause to let other queries through
ENDLOOP;END$$;
-- This scans the entire orders table
ALTERTABLEordersADDCONSTRAINTfk_customerFOREIGNKEY(customer_id)REFERENCEScustomers(id);
For large tables, add the constraint as NOT VALID first:
1
2
3
4
5
6
7
8
-- Instant: doesn't validate existing data
ALTERTABLEordersADDCONSTRAINTfk_customerFOREIGNKEY(customer_id)REFERENCEScustomers(id)NOTVALID;-- Then validate in background (can be interrupted)
ALTERTABLEordersVALIDATECONSTRAINTfk_customer;