The numbers behind the rules
Every rule page that says a change blocks traffic shows how long, from this run: the dangerous form and the safe form of the same change, on the same table, while two other sessions keep writing and reading. Nothing here is asserted from documentation; it is what the server did.
The setup
- Postgres
- 18.6 (postgres:18-alpine)
- Table
- 20M rows, 1.65 GB,
orderswith a bigserial key, an int, a text, a numeric and a timestamptz - Probes
- one session inserting a row and one reading a row by key, every 50 ms, each call timed
- Hardware
- Apple M1 Max, 10 cores, 69 GB, darwin 25.5.0
- Run
- Sep 16, 2026, throwaway container, destroyed afterwards
Worst wait is the slowest single probe call while the statement ran. Lock modes are read from pg_locks before COMMIT for transactional forms and sampled every 100 ms otherwise; a rewrite is a change of the table's relfilenode. Laptop hardware, so the absolute times are small and the ratios are what matter.
Every scenario
One card per rule. The headline is how long ordinary traffic was stuck behind the change: the worst single insert or read while the statement ran, dangerous form first, safe form after the arrow. Below it, what each form was and how long the statement itself took.
BV002ADD COLUMN NOT NULL without defaultrefused by the server, no lock heldFiresADD COLUMN ... NOT NULL, no defaultSafeADD COLUMN ... NOT NULL DEFAULT 'none'BV003Non-concurrent index creation traffic blocked 6.3 s15 msFiresCREATE INDEXSafeCREATE INDEX CONCURRENTLYBV004Column type change forcing a table rewrite traffic blocked 11.2 s2 msFiresALTER COLUMN customer_id TYPE bigint (rewrite)Safeexpand step: ADD COLUMN customer_id_new bigintBV007Volatile column default forcing a table rewrite traffic blocked 15.2 s3 msFiresADD COLUMN ... DEFAULT clock_timestamp() (volatile)SafeADD COLUMN ... DEFAULT now() (stable)BV008Foreign key added without NOT VALID traffic blocked 10.8 s9 msFiresADD CONSTRAINT ... FOREIGN KEY (validates under lock)SafeNOT VALID, then VALIDATE CONSTRAINTBV009CHECK constraint added without NOT VALID traffic blocked 973 ms2 msFiresADD CONSTRAINT ... CHECK (scans under lock)SafeNOT VALID, then VALIDATE CONSTRAINTBV011SET NOT NULL scanning the table under ACCESS EXCLUSIVE traffic blocked 954 ms1 msFiresSET NOT NULL (full scan under ACCESS EXCLUSIVE)SafeCHECK ... IS NOT NULL NOT VALID, VALIDATE, then SET NOT NULLBV012PRIMARY KEY or UNIQUE constraint building its index under full lock traffic blocked 3.8 s179 msFiresADD CONSTRAINT ... UNIQUE (index built under lock)SafeCREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEXBV015VACUUM FULL / CLUSTER / REINDEX rewriting under full lock traffic blocked 9.0 s89 msFiresVACUUM FULL (rewrite under ACCESS EXCLUSIVE)SafeVACUUM (ANALYZE), no rewriteBV016DROP INDEX without CONCURRENTLY traffic blocked 10.0 s17 msFiresDROP INDEX behind an open readSafeDROP INDEX CONCURRENTLY behind the same readBV020Partition attach/detach blocking the partition tree traffic blocked 2.4 s242 msFiresATTACH PARTITION without a matching CHECK (scans the partition under lock)SafeCHECK proving the bound, added NOT VALID and validated, then ATTACH PARTITIONBV021Materialized view refreshed without CONCURRENTLY traffic blocked 1.4 s6 msFiresREFRESH MATERIALIZED VIEW (readers blocked)SafeREFRESH MATERIALIZED VIEW CONCURRENTLYBV024Stored generated column added to an existing table traffic blocked 18.2 s2 msFiresADD COLUMN ... GENERATED ALWAYS AS (...) STORED (rewrite)SafeADD COLUMN total_cents bigint, computed by the application or a later backfillBV034Exclusive-lock DDL without a lock_timeout guard traffic blocked 10.0 s2.0 sFiresALTER TABLE ... ADD COLUMN behind an idle transaction, no lock_timeoutSafeSET lock_timeout = '2s', then the same ALTER TABLEBV045Database-wide REINDEX in a migration traffic blocked 10.2 s688 msFiresREINDEX TABLE (every index rebuilt under lock)SafeREINDEX TABLE CONCURRENTLY
"Refused" is the server rejecting the statement outright (a NOT NULL column with no default on a populated table); "gave up" is a lock_timeout doing its job. Both are the rule being right, not the bench failing. A form that takes as long as the dangerous one but blocks nobody (CONCURRENTLY) is the point: the time moves off the users. Rules with no scenario have nothing to time: destructive changes, type choices, credentials and set ordering are judged from the statement alone.
Run it yourself
The scenarios, the runner and this run's results are in the public repository under packages/engine/bench. One command with Docker present; the file it writes is what this page and the rule pages are built from.
git clone https://github.com/bolvrk/bolvrk
cd bolvrk && bun install
bun run bench # every scenario, 20M rows, postgres:18-alpine
bun run bench -- --rule BV003 # one rule
bun run bench -- --image postgres:18-alpineDisagree with a number? The scenario is a few lines of SQL you can read and change; a pull request with a better one is the right reply. packages/engine/bench on GitHub