Playbook library

Database migrations & schema change

A migration is the one kind of diff where correct code can still cause an outage. Generated migrations are syntactically perfect and operationally naive: they add `NOT NULL` to a populated table, build an index in a transaction, and drop a column the still-running old process is writing to. These rules encode expand-migrate-contract as reviewable statements.

Written for
Teams doing rolling deploys against a live Postgres or MySQL database.
Not covered
Written for Postgres-style semantics; a few rules (`CONCURRENTLY`, `timestamptz`) need translation for MySQL. Nothing here checks query performance — that is a load test, not a review.

11 rules6 blocking3 warning2 info

Start a free playbook →

01 One migration per PR, with its rollback

error

A pull request contains at most one schema migration, and the down/rollback path ships in the same PR. Migrations bundled together cannot be reverted independently, and you discover you needed the rollback at 2am — not at review time, when writing it takes four minutes.

Blocks: without a rollback in the diff, the incident response plan is improvisation.

Scopemigrations/**db/migrations/****/drizzle/**

Tagsdatabasesafetyprocess

02 Migrations take a bounded lock

error

A migration that alters an existing table sets a short `lock_timeout` (and a `statement_timeout`) at the top of the file, so DDL that cannot acquire its lock fails fast instead of waiting. A pending `ALTER` queues behind the transaction it is waiting on, and every later query on that table queues behind the `ALTER` — so a lock the migration never gets takes the table down for as long as it keeps trying.

Blocks: without it, one blocked statement stalls every read and write on the table — an outage rather than a failed deploy.

Scopemigrations/**db/migrations/****/drizzle/**

Tagsdatabasezero-downtimelocking

03 Destructive changes come one release later

error

Dropping or renaming a column or table happens in a separate, later PR than the code change that stopped using it — expand, migrate, contract. During a rolling deploy old and new processes run simultaneously, so a column dropped in the same release breaks every request still served by the old version.

Blocks: this is the single most common cause of deploy-time 500s on a healthy codebase.

Scopemigrations/**db/migrations/****/drizzle/**

Tagsdatabasezero-downtime

04 New columns are nullable or defaulted, then backfilled

error

Adding a `NOT NULL` column without a default to a populated table is rejected. Add it nullable (or with a default), backfill in batches, then tighten the constraint in a later migration. The single-statement version rewrites or exclusively locks the table for as long as the rewrite takes.

Blocks: it takes a write lock on a live table — an outage with no warning in the diff.

Scopemigrations/**db/migrations/****/drizzle/**

Tagsdatabasezero-downtimelocking

05 Build indexes concurrently, outside a transaction

error

`CREATE INDEX` and `DROP INDEX` on a table with production volume use `CONCURRENTLY`, and the migration is marked as non-transactional. A plain `CREATE INDEX` holds a lock that blocks every write to the table until it finishes — which on a large table is minutes, not seconds.

Blocks: same reasoning — a lock on a live table, invisible unless someone knows to look.

Scopemigrations/**db/migrations/****/drizzle/**

Tagsdatabasezero-downtimelocking

06 Money and precision columns are never floats

error

Monetary and precise-quantity columns use `numeric`/`decimal` or an integer minor-unit column. `float`, `real` and `double precision` are rejected for these. Floating point cannot represent decimal fractions exactly, so sums stop reconciling and the discrepancy grows with volume.

Blocks: changing a column type later means a full-table rewrite plus a data audit.

Scopemigrations/**db/migrations/****/schema*.ts**/models/**

Tagsdatabasecorrectnessbilling

07 Backfills run in bounded batches

warning

A migration that touches existing rows processes them in batches with an explicit bound and a resume key, or runs as a separate job rather than inside the migration. A single `UPDATE` across the whole table holds one long transaction, blocks autovacuum, and generates WAL faster than replicas can consume it.

Warns: on a small table it is harmless — the reviewer decides based on real row counts.

Scopemigrations/**db/migrations/**

Tagsdatabaseperformance

08 Foreign keys get an index on the child column

warning

Every foreign key column has an index, created in the same migration. Without it, a delete or update on the parent row triggers a sequential scan of the child table per affected row — fast in development with a hundred rows, an incident at ten million.

Warns: purely a performance cliff, and it is easy to add afterwards.

Scopemigrations/**db/migrations/****/schema*.ts

Tagsdatabaseperformance

09 Timestamps are timezone-aware

warning

Time columns use `timestamptz` (or the equivalent) and default to the database's UTC now. `timestamp without time zone` is rejected. A naive timestamp silently means "whatever timezone the writing process had", which changes when a server moves region or a worker runs somewhere else.

Warns: the damage is gradual data ambiguity, not immediate breakage.

Scopemigrations/**db/migrations/****/schema*.ts

Tagsdatabasetime

10 Keep data changes out of schema migrations

info

Seeds, corrections and one-off data fixes live in their own migration or script, never mixed with DDL in the same file. DDL and DML have different lock profiles and different rollback stories — mixing them means the safe half cannot be reverted without the risky half.

Informational: a hygiene rule that pays off only when something goes wrong.

Scopemigrations/**db/migrations/**

Tagsdatabaseprocess

11 Naming follows the schema convention

info

Tables are snake_case and plural, foreign keys are `<singular>_id`, booleans read as predicates (`is_active`, `has_access`), and reserved words are avoided. Consistency here is what lets anyone — human or agent — guess a column name correctly instead of reading the schema first.

Informational: taste, but consistent taste is what makes the schema navigable.

Scopemigrations/**db/migrations/****/schema*.ts

Tagsdatabaseconventions

Before you import all of it

Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.

Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.

Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.

The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.

Your agents already code fast. Make them code like your team.

Start free — no card required