Skip to content

The type-checker for PostgreSQL migrations

Execute a proposed schema change against your production data's shape in a disposable PostgreSQL, and get back a machine-readable verdict — before the migration reaches production.
demo/repo/migrations/naive/001_add_email.sql
ALTER TABLE public.users
ADD COLUMN email text NOT NULL DEFAULT (gen_random_uuid()::text || '@users.invalid');

The obvious way to add a NOT NULL email column, and it passes on any dev database. But the demo app’s committed fixture says users has 5,000,000 rows. rowshape validate hydrates a disposable Postgres to that shape, applies the migration, and reports what happened there:

Terminal
$ rowshape validate demo/repo/rowshape.yaml --migrations demo/repo/migrations/naive --ephemeral "$PG" --statement-timeout 5m
[WARN] WARN (fixture demo-users)
! RS-LOCK-001 ACCESS EXCLUSIVE lock on users, slow rewrite of 5.0M rows
at demo/repo/migrations/naive/001_add_email.sql:1
ADD COLUMN with a volatile default holds ACCESS EXCLUSIVE and rewrites all 5000000 rows.
duration: slow (extrapolated from 5000000 rows in 22736ms, linear model, 5000000 rows declared)
rests on: public.users.rows [confidence: exact]
fix: Avoid the full-table ACCESS EXCLUSIVE rewrite. For a volatile default: ADD the column nullable with no default, backfill in batches, then attach the default and SET NOT NULL via a validated CHECK. For a type change: add a new column of the target type, backfill it in batches, swap reads/writes, and drop the old column. Each step is online.
why: rowshape explain RS-LOCK-001
! RS-LOCK-010 This migration takes ACCESS EXCLUSIVE without setting lock_timeout
The first locking statement is a ADD COLUMN with a volatile default. With no lock_timeout, a DDL statement that cannot acquire its lock immediately WAITS — and while it waits, every new query on that table queues behind it, because a pending ACCESS EXCLUSIVE request blocks incoming readers too. A migration that is instant in isolation can therefore stall an entire table behind one long-running transaction it happened to collide with. This is a property of the migration as a whole, so no single statement looks wrong.
fix: Set a short timeout at the head of the migration: SET lock_timeout = '3s'; so a statement that cannot get its lock fails fast instead of queueing every reader behind it. Pair it with a retry: a failed migration you can re-run is strictly better than a stalled table. For operations that must eventually succeed on a busy table, retry in a loop with backoff rather than raising the timeout. Note that SET LOCAL scopes it to the transaction, which is usually what you want inside a migration.
why: rowshape explain RS-LOCK-010
duration: 23101ms

The exit code is 2: WARN, a migration that works and costs something worth knowing — here, every write to users blocked for the length of the rewrite. Rewrite it as the three online steps RS-LOCK-001 names (demo/repo/migrations/rewrite/) and the same command returns:

Terminal
[PASS] PASS (fixture demo-users)
no findings
  1. rowshape pull reads the shape of your database through catalog views only — never SELECT * on a user table — with a read-only role, and writes rowshape.yaml: row counts, null fractions, cardinality, fan-out. Statistics, no rows. You commit it.

  2. rowshape hydrate rebuilds a disposable Postgres from that file, with synthesized rows whose shape matches production. The same fixture, --seed and engine version produce the same data on any machine.

  3. rowshape validate applies your migration to the hydrated copy, captures what happened — locks, durations, rows touched, constraint violations — and returns PASS, WARN or FAIL with exit code 0, 2 or 1 (3 when it could not decide). With --ephemeral it does the hydrate step itself and drops the database afterwards.

Terminal window
# Read the shape (read-only role) and commit it: it contains no rows.
rowshape pull "$DATABASE_URL" -o rowshape.yaml
git add rowshape.yaml
# Check a migration against it in a throwaway database.
rowshape validate rowshape.yaml -m migrations/ --ephemeral "$SCRATCH_PG"

The quickstart walks through it end to end; every flag is in the CLI reference.

A migration that passes every test on a dev database with twelve rows in it can still take production down. SET NOT NULL on a column that is 3% null fails on contact. CREATE INDEX without CONCURRENTLY blocks every write to the table for the length of the build. A cascade delete on a foreign key with a long fan-out tail is an outage, not a cleanup. The shape of the data decides the answer, and the shape is exactly what a dev database does not have.

rowshape checks a PostgreSQL migration against that shape before it runs, and tells you which lock it takes, how long it holds it, and which existing rows will make it fail. MySQL is on the roadmap, behind the fixture spec’s vocabulary; today the answer is Postgres.

Shape, not rows

A fixture is statistics — row counts, null fractions, cardinality, fan-out distributions. No rows. It commits to your repo and is small enough to review.

One verdict, two readers

The CLI, the MCP server, and the GitHub Action all render the same verdict struct. A human and an agent get the same answer through the same contract.

Confidence is part of the answer

A verdict is capped by the weakest fact it rests on. If rowshape could only estimate something, it says WARN and names the command that proves it — it does not guess PASS.

Built for the agent loop

rowshape mcp puts the shape and the verdict inside the agent’s turn, so it can write, validate, read the failure, and fix — without a human round trip.