Zero-Downtime Migrations skill for Codex
Change big tables without a lock-queue outage. Codex classifies every migration statement by the lock it takes and whether it rewrites the table, then restructures risky changes into expand, migrate and contract deploys that old and new code both survive. It adds lock_timeout guards, NOT VALID constraints and concurrent index builds, batches backfills, adapts the recipe to Rails, Django, Alembic or Prisma, and waits for approval before production runs or drops.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- zero-downtime-migrations Installs to ~/.agents/skills/zero-downtime-migrations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install zero-downtime-migrations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project zero-downtime-migrations Installs to .agents/skills/zero-downtime-migrations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/zero-downtime-migrations.zipunzip -o zero-downtime-migrations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) zero-downtime-migrations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $zero-downtime-migrations, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Plans and writes database schema migrations that run while the app serves traffic - expand/contract sequencing across deploys, lock_timeout guards, NOT VALID constraints, concurrent index builds, safe column renames and type changes, batched backfills - mainly for PostgreSQL, with notes for MySQL. Reviews Rails, Django, Prisma, Alembic and raw SQL migrations for statements that take long ACCESS EXCLUSIVE locks or rewrite tables. Use when writing or reviewing a migration against a large or busy table, renaming or retyping a column, adding NOT NULL or a foreign key, or planning a multi-deploy schema change.
Use it when
- Writing or reviewing a migration on a large or busy table
- Renaming, retyping or dropping a column that running code uses
- Adding NOT NULL, a foreign key, a unique constraint or an index in production
- A past migration caused a lock pile-up or outage
Use something else when
- The backfill itself is the main job (hours of batched updates): use data-backfills
- MySQL tables that need gh-ost or pt-online-schema-change: use mysql-online-schema-change
How to check the result
Each statement is classified by lock and rewrite behaviour, lock_timeout is set, the migration is timed on production-sized data, and the previous app version passes its smoke tests against the new schema.
The mistake it prevents
Assuming a metadata-only ALTER is harmless: without lock_timeout it queues behind one long query and blocks every read on the table.
A migration is safe when every statement either finishes in milliseconds or holds only locks that let reads and writes continue, when old and new application versions both work against every intermediate schema, and when each step can be stopped or reverted. “It ran fine locally” proves nothing: the dev table has 50 rows and no traffic.
The lock queue problem
Most outages come from short locks, not long ones. ALTER TABLE needs an ACCESS
EXCLUSIVE lock. If a 10-minute report query holds a lock on the table, the ALTER waits,
and every later query on that table queues behind the ALTER. The site is down for 10
minutes because of a statement that would take 5 ms. Always guard DDL:
SET lock_timeout = '3s'; -- give up instead of queueing everyone behind us
SET statement_timeout = '15min'; -- for the statement itself (raise for index builds)
Retry a timed-out step a few times with backoff; if it keeps failing, find the blocker
(SELECT pid, now() - xact_start, state, query FROM pg_stat_activity ORDER BY 2 DESC).
Workflow
- Read
AGENTS.mdand the migration tool docs used in the repo. Note whether each migration runs in a transaction (Rails, Django, Alembic, Flyway do by default) and how deploys order “migrate” vs “release new code”. - Get table facts:
SELECT pg_size_pretty(pg_total_relation_size('t')), n_live_tup FROM pg_stat_user_tables WHERE relname = 't';and its write rate. Under ~10k rows most things are safe; above 1M, treat every statement as dangerous until checked against the lock table below. - Classify each statement with references/lock-matrix.md (read it for any statement not covered in the quick table).
- Split into expand / migrate / contract phases, each its own deploy.
- Add guards (
lock_timeout), concurrency flags and batching. - Run the migration against a production-sized copy or at least a table with millions of seeded rows, and time each statement.
- Present the deploy plan to the user. Running migrations against production, and any contract step that drops columns or tables, requires the user’s explicit go-ahead.
Quick safety table (PostgreSQL 12+)
| Change | Safe? | Safe recipe |
|---|---|---|
| Add nullable column, or with a constant default | Yes (11+, metadata only) | Plain ADD COLUMN with lock_timeout |
Add column with volatile default (clock_timestamp(), gen_random_uuid()) | No, rewrites table | Add nullable, backfill in batches, then set default |
| Add NOT NULL to existing column | Scans table under ACCESS EXCLUSIVE | CHECK NOT VALID, VALIDATE, SET NOT NULL, drop CHECK (below) |
| Add foreign key | Locks both tables while scanning | ADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT |
| Add CHECK | Scans under lock | NOT VALID, then VALIDATE |
| Add index | Blocks writes | CREATE INDEX CONCURRENTLY outside a transaction |
| Add unique constraint | Blocks writes | Unique index concurrently, then ADD CONSTRAINT ... UNIQUE USING INDEX |
| Change column type | Usually rewrites table | New column + dual write + backfill + swap (expand/contract) |
| Rename column or table | Instant, but breaks running code | Expand/contract, or a view/compat layer |
| Drop column | Instant, but breaks code still selecting it | Remove from code first (and ORM ignored-columns), then drop |
| Drop table | Instant, irreversible | Rename to _deprecated, wait a release, then drop with approval |
| Set or drop default | Instant | Fine |
Recipes
NOT NULL on a big table
SET lock_timeout = '3s';
ALTER TABLE orders ADD CONSTRAINT orders_customer_id_not_null
CHECK (customer_id IS NOT NULL) NOT VALID; -- instant
-- separate transaction / migration:
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_not_null; -- scans, allows writes
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL; -- PG 12+ uses the valid CHECK, no scan
ALTER TABLE orders DROP CONSTRAINT orders_customer_id_not_null;
On PG 18 you can instead add NOT NULL itself as NOT VALID and validate it later;
use the CHECK route when the fleet has older versions.
Foreign key
ALTER TABLE order_items ADD CONSTRAINT order_items_order_id_fkey
FOREIGN KEY (order_id) REFERENCES orders (id) NOT VALID;
ALTER TABLE order_items VALIDATE CONSTRAINT order_items_order_id_fkey;
Create the index on order_items (order_id) concurrently first, or deletes on orders
will scan order_items.
Rename or retype a column (expand/contract)
| Deploy | Schema | Application |
|---|---|---|
| 1 Expand | Add email_normalized (nullable) | Write both columns; read old |
| 2 Migrate | Batched backfill; add constraints NOT VALID then validate | Write both; read old |
| 3 Switch | (none) | Read new; still write both |
| 4 Contract | Drop old column after one release with no reads | Write new only |
A trigger can do the dual write in deploy 1 if several services write the table; it must be removed in the contract phase.
Backfills
Never UPDATE big_table SET x = ... in one statement: it holds row locks for the whole
run, bloats the table and can build replication lag. Batch by primary key, commit per
batch, and throttle:
UPDATE orders SET email_normalized = lower(email)
WHERE id IN (
SELECT id FROM orders
WHERE id > :last_id AND email_normalized IS NULL
ORDER BY id LIMIT 5000)
RETURNING id; -- carry max(id) forward as :last_id; sleep between batches
Monitor replica lag and stop if it climbs. Large or long-running backfills belong in a
job, not a migration: see $data-backfills.
Framework notes
- Rails:
disable_ddl_transaction!for concurrent indexes; thestrong_migrationsgem flags most unsafe operations. Add removed columns toself.ignored_columnsbefore dropping them. - Django:
AddIndexConcurrently/RemoveIndexConcurrentlyfromdjango.contrib.postgres.operationswithatomic = False. UseSeparateDatabaseAndStateto deploy code and schema changes in different releases. Review withsqlmigrate. - Alembic:
with op.get_context().autocommit_block():for concurrent statements; inspect SQL withalembic upgrade head --sql. - Prisma / Drizzle: generated SQL is plain DDL with no lock guards. Edit the
generated migration file (Prisma:
prisma migrate dev --create-only) to addCONCURRENTLY,NOT VALIDandlock_timeout; concurrent index builds must not sit in a transaction. - Linting:
squawklints raw Postgres migration SQL for these patterns; run it in CI if the repo uses SQL files. For expand/contract with automatic views,pgrollis an option.
MySQL differences
- Prefer
ALGORITHM=INSTANT(8.0.29+ supports add/drop column at any position) orALGORITHM=INPLACE, LOCK=NONE, and state them explicitly so MySQL errors instead of silently falling back to a table copy. - Metadata locks behave like the Postgres lock queue: set
lock_wait_timeoutlow for the DDL session. - For large tables that need a copy, use gh-ost or pt-online-schema-change (
$mysql-online-schema-change).
Gotchas
- Mixing a concurrent index build with other statements in one migration file fails or silently runs non-concurrently, depending on the tool. One concurrent statement per migration.
- Enum value additions (
ALTER TYPE ... ADD VALUE) cannot be used in the same transaction. DEFAULTchanges apply only to new rows; existing NULLs still need a backfill.- Deploying code that reads a new column before the migration ran causes errors on the first instances to boot. Order: migrate (expand) before deploy; contract after.
- Long-running transactions block concurrent index builds and VALIDATE from finishing.
- Rollbacks: a
downthat recreates a dropped column does not restore its data. Treat contract steps as irreversible and take a backup or snapshot first.
Verification
- Every statement classified by lock level and rewrite behaviour
-
lock_timeoutset for every DDL; concurrent operations outside transactions - Old app version works against the post-migration schema (run its test suite or smoke test against it)
- Timed on production-sized data; longest lock-holding statement under ~1 s
- Invalid indexes checked after concurrent builds (
SELECT * FROM pg_index WHERE NOT indisvalid) - Destructive contract steps listed separately and approved by the user
Output template
## Migration plan: <change>
**Table:** <name>, <rows>, <size>, <writes/s>
| Step | Deploy | SQL | Lock | Duration (measured) | Revert |
|---|---|---|---|---|---|
**App compatibility:** <which app versions work with which step>
**Backfill:** <batch size, throttle, est. time, monitoring>
**Needs approval:** <drops, production run> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Zero-Downtime Migrations"
short_description: "Change big tables without a lock-queue outage"
default_prompt: "$zero-downtime-migrations Add a NOT NULL constraint to the 80M-row events table without locking it" references/lock-matrix.md
# PostgreSQL DDL lock and rewrite matrix
Read this when a migration contains a statement not covered by the quick table in
SKILL.md, or when you need to explain exactly what a statement blocks. Applies to
PostgreSQL 12 and later unless a version is noted.
## Lock levels, weakest to strongest
| Lock | Taken by (examples) | Blocks |
|---|---|---|
| ACCESS SHARE | `SELECT` | Only ACCESS EXCLUSIVE |
| ROW SHARE | `SELECT ... FOR UPDATE/SHARE` | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | `INSERT`, `UPDATE`, `DELETE`, `MERGE` | SHARE and stronger |
| SHARE UPDATE EXCLUSIVE | `VACUUM`, `ANALYZE`, `CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`, some `ALTER TABLE SET` | Itself and stronger, plus other schema changes. Reads and writes continue |
| SHARE | `CREATE INDEX` (non-concurrent) | All writes |
| SHARE ROW EXCLUSIVE | `ADD FOREIGN KEY`, `CREATE TRIGGER` | All writes |
| EXCLUSIVE | `REFRESH MATERIALIZED VIEW CONCURRENTLY` | Writes and `FOR UPDATE` reads |
| ACCESS EXCLUSIVE | Most `ALTER TABLE`, `DROP`, `TRUNCATE`, `VACUUM FULL`, `CLUSTER`, `REFRESH MATERIALIZED VIEW` | Everything, including plain `SELECT` |
Any lock, even one that blocks nothing currently running, joins the queue. A waiting
ACCESS EXCLUSIVE request blocks every later request that conflicts with it. That is why
`lock_timeout` matters even for "instant" statements.
## Statement reference
| Statement | Lock on table | Rewrite / full scan | Notes |
|---|---|---|---|
| `ADD COLUMN` nullable, no default | ACCESS EXCLUSIVE (brief) | No | |
| `ADD COLUMN ... DEFAULT <constant or stable expr>` | ACCESS EXCLUSIVE (brief) | No (11+) | `now()` is fine: evaluated once |
| `ADD COLUMN ... DEFAULT <volatile expr>` | ACCESS EXCLUSIVE | Rewrite | e.g. `clock_timestamp()`, `random()`, `gen_random_uuid()` |
| `ADD COLUMN ... GENERATED ALWAYS AS (...) STORED` | ACCESS EXCLUSIVE | Rewrite | PG 18 `VIRTUAL` generated columns do not rewrite |
| `ADD COLUMN ... GENERATED ... AS IDENTITY` | ACCESS EXCLUSIVE | Rewrite | Fills every existing row |
| `DROP COLUMN` | ACCESS EXCLUSIVE (brief) | No | Space reclaimed only by later rewrites |
| `ALTER COLUMN TYPE` | ACCESS EXCLUSIVE | Rewrite + index rebuild | Exceptions below |
| `ALTER COLUMN TYPE varchar(n)` -> larger `varchar(m)` or `text` | ACCESS EXCLUSIVE (brief) | No | Binary-coercible, no rewrite |
| `ALTER COLUMN TYPE numeric(p,s)` -> larger `p`, same `s` | ACCESS EXCLUSIVE (brief) | No | |
| `ALTER COLUMN TYPE int` -> `bigint` | ACCESS EXCLUSIVE | Rewrite | Use a new column + backfill + swap |
| `ALTER COLUMN TYPE timestamp` -> `timestamptz` | ACCESS EXCLUSIVE | No rewrite only when `TimeZone` is UTC (12+) | Verify on a copy |
| `ALTER COLUMN SET NOT NULL` | ACCESS EXCLUSIVE | Full scan, unless a valid `CHECK (col IS NOT NULL)` exists | |
| `ALTER COLUMN DROP NOT NULL` | ACCESS EXCLUSIVE (brief) | No | |
| `ALTER COLUMN SET DEFAULT` / `DROP DEFAULT` | ACCESS EXCLUSIVE (brief) | No | |
| `ALTER COLUMN SET STATISTICS` | SHARE UPDATE EXCLUSIVE | No | |
| `ADD CONSTRAINT CHECK` | ACCESS EXCLUSIVE | Full scan | Use `NOT VALID` |
| `ADD CONSTRAINT CHECK ... NOT VALID` | ACCESS EXCLUSIVE (brief) | No | New writes are checked immediately |
| `ADD CONSTRAINT FOREIGN KEY` | SHARE ROW EXCLUSIVE on both tables | Full scan | Use `NOT VALID` |
| `ADD CONSTRAINT FOREIGN KEY ... NOT VALID` | SHARE ROW EXCLUSIVE on both (brief) | No | |
| `VALIDATE CONSTRAINT` | SHARE UPDATE EXCLUSIVE (ROW SHARE on referenced table for FKs) | Full scan | Reads and writes continue |
| `ADD CONSTRAINT UNIQUE / PRIMARY KEY` | ACCESS EXCLUSIVE | Index build under lock | Build index concurrently, then `USING INDEX` |
| `ADD CONSTRAINT ... USING INDEX` | ACCESS EXCLUSIVE (brief) | No (PK also needs columns NOT NULL) | |
| `ADD CONSTRAINT EXCLUDE` | ACCESS EXCLUSIVE | Index build under lock | No concurrent variant; schedule a window on big tables |
| `DROP CONSTRAINT` | ACCESS EXCLUSIVE (brief) | No | |
| `RENAME COLUMN / TABLE / CONSTRAINT` | ACCESS EXCLUSIVE (brief) | No | Breaks running code |
| `CREATE INDEX` | SHARE | Build | Blocks writes for the whole build |
| `CREATE INDEX CONCURRENTLY` | SHARE UPDATE EXCLUSIVE | Two scans | Not in a transaction; leaves INVALID index on failure |
| `DROP INDEX` | ACCESS EXCLUSIVE (brief) | No | |
| `DROP INDEX CONCURRENTLY` | SHARE UPDATE EXCLUSIVE | No | Not in a transaction |
| `REINDEX` | ACCESS EXCLUSIVE on index, SHARE on table | Build | |
| `REINDEX CONCURRENTLY` | SHARE UPDATE EXCLUSIVE | Build | 12+ |
| `ALTER TABLE SET (autovacuum_...)` | SHARE UPDATE EXCLUSIVE | No | |
| `ALTER TABLE SET LOGGED / UNLOGGED` | ACCESS EXCLUSIVE | Rewrite | |
| `ATTACH PARTITION` | SHARE UPDATE EXCLUSIVE on parent, ACCESS EXCLUSIVE on the attached table | Scans attached table unless a matching valid CHECK exists | Also scans the default partition if one exists |
| `DETACH PARTITION` | ACCESS EXCLUSIVE on parent | No | |
| `DETACH PARTITION CONCURRENTLY` | SHARE UPDATE EXCLUSIVE on parent | No | 14+, not in a transaction, no default partition |
| `CREATE TRIGGER` | SHARE ROW EXCLUSIVE | No | |
| `TRUNCATE` | ACCESS EXCLUSIVE | No | Destructive: needs user approval |
| `VACUUM FULL`, `CLUSTER` | ACCESS EXCLUSIVE | Rewrite | Use `pg_repack` for online rebuilds |
| `ALTER TYPE ... ADD VALUE` (enum) | Lock on the type | No | New value unusable until the transaction commits |
## Checking what a statement locks
Run the statement in a transaction on a staging copy and inspect locks from another
session before rolling back:
```sql
BEGIN;
ALTER TABLE orders ADD COLUMN note text;
SELECT locktype, relation::regclass, mode, granted
FROM pg_locks WHERE pid = pg_backend_pid();
ROLLBACK;
```
To see whether a statement rewrites the table, compare `pg_relation_filenode('orders')`
before and after on a copy: a new filenode means a rewrite.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.