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.
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:
$ 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: 23101msThe 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:
[PASS] PASS (fixture demo-users) no findingsrowshape 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.
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.
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.
# Read the shape (read-only role) and commit it: it contains no rows.rowshape pull "$DATABASE_URL" -o rowshape.yamlgit 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.
- uses: rowshape/rowshape@v1 with: fixture: rowshape.yaml migrations: migrations/ # A `services: postgres` container in the same job. ephemeral: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disableFAIL fails the job; WARN passes unless warn-as-fail: true. Findings land as
annotations on the lines that caused them. See the
GitHub Action reference.
rowshape init --agentRegisters rowshape mcp in your agent’s MCP config, writes a versioned agent
rule, and adds a pre-commit hook in a git repo. The config entry it writes:
{ "mcpServers": { "rowshape": { "args": ["mcp"], "command": "rowshape" } }}The agent gets four tools — describe_shape, validate_migration,
explain_finding, plan_against. See MCP tools.
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.