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.

  • Databases
  • $zero-downtime-migrations
  • 178 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- zero-downtime-migrations

Installs to ~/.agents/skills/zero-downtime-migrations/ for every project on this machine.

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

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.

~/.agents/skills/zero-downtime-migrations/SKILL.md

--- name: zero-downtime-migrations description: Plans and writes database schema migrations that run while the app serves traffic - expand/contract sequencing across deploys, lock_timeout guards,… license: MIT ---

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

  1. Read AGENTS.md and 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”.
  2. 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.
  3. Classify each statement with references/lock-matrix.md (read it for any statement not covered in the quick table).
  4. Split into expand / migrate / contract phases, each its own deploy.
  5. Add guards (lock_timeout), concurrency flags and batching.
  6. Run the migration against a production-sized copy or at least a table with millions of seeded rows, and time each statement.
  7. 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+)

ChangeSafe?Safe recipe
Add nullable column, or with a constant defaultYes (11+, metadata only)Plain ADD COLUMN with lock_timeout
Add column with volatile default (clock_timestamp(), gen_random_uuid())No, rewrites tableAdd nullable, backfill in batches, then set default
Add NOT NULL to existing columnScans table under ACCESS EXCLUSIVECHECK NOT VALID, VALIDATE, SET NOT NULL, drop CHECK (below)
Add foreign keyLocks both tables while scanningADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT
Add CHECKScans under lockNOT VALID, then VALIDATE
Add indexBlocks writesCREATE INDEX CONCURRENTLY outside a transaction
Add unique constraintBlocks writesUnique index concurrently, then ADD CONSTRAINT ... UNIQUE USING INDEX
Change column typeUsually rewrites tableNew column + dual write + backfill + swap (expand/contract)
Rename column or tableInstant, but breaks running codeExpand/contract, or a view/compat layer
Drop columnInstant, but breaks code still selecting itRemove from code first (and ORM ignored-columns), then drop
Drop tableInstant, irreversibleRename to _deprecated, wait a release, then drop with approval
Set or drop defaultInstantFine

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)

DeploySchemaApplication
1 ExpandAdd email_normalized (nullable)Write both columns; read old
2 MigrateBatched backfill; add constraints NOT VALID then validateWrite both; read old
3 Switch(none)Read new; still write both
4 ContractDrop old column after one release with no readsWrite 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; the strong_migrations gem flags most unsafe operations. Add removed columns to self.ignored_columns before dropping them.
  • Django: AddIndexConcurrently / RemoveIndexConcurrently from django.contrib.postgres.operations with atomic = False. Use SeparateDatabaseAndState to deploy code and schema changes in different releases. Review with sqlmigrate.
  • Alembic: with op.get_context().autocommit_block(): for concurrent statements; inspect SQL with alembic 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 add CONCURRENTLY, NOT VALID and lock_timeout; concurrent index builds must not sit in a transaction.
  • Linting: squawk lints raw Postgres migration SQL for these patterns; run it in CI if the repo uses SQL files. For expand/contract with automatic views, pgroll is an option.

MySQL differences

  • Prefer ALGORITHM=INSTANT (8.0.29+ supports add/drop column at any position) or ALGORITHM=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_timeout low 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.
  • DEFAULT changes 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 down that 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_timeout set 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.yamlgenerated from this page

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.md83 lines, 5.7 KB
# 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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem