Zero-Downtime Migrations

Old and New Code Run Together

With rolling deploys, version N and version N+1 of the app run at the same time against the same schema for a few minutes. So every migration must work with both the old and the new code. The pattern is expand → migrate → contract:

Rename products.title → products.name without downtime

 Release 1 (expand)   ADD COLUMN name; app writes BOTH title and name; reads title
 Backfill job         UPDATE products SET name = title WHERE name IS NULL (in chunks)
 Release 2            app reads name, still writes both
 Release 3 (contract) app stops using title; migration DROPs title

Safe vs Risky Changes

ChangeSafe in one step?How to do it
Add nullable columnyesjust add it
Add NOT NULL columnnoadd nullable → backfill → add constraint
Rename column/tablenoexpand/contract as above
Drop columnonly after no code uses itremove from entity first, drop in a later release
Add index on a big tablelocks writesCREATE INDEX CONCURRENTLY (PostgreSQL), ALGORITHM=INPLACE, LOCK=NONE (MySQL)
Change column typenonew column + backfill + switch

Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction. In Flyway, put it in its own script and allow non-transactional statements (Flyway detects this for PostgreSQL), or use -- flyway:executeInTransaction=false in a script config file.

Exercise

Exercise: You need to make users.phone NOT NULL. 5% of 3 million rows have no phone. Write the release plan.

Show answer
Release 1: app requires a phone for new sign-ups and on profile save (validation).
Migration A: nothing yet (column stays nullable).
Backfill: ask users to add a phone at next login, or set a placeholder value
          in chunks of 10,000 rows (UPDATE ... WHERE id BETWEEN ? AND ? AND phone IS NULL).
Release 2 migration: when "SELECT count(*) FROM users WHERE phone IS NULL" = 0,
          ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
          then mark the entity field @Column(nullable = false).