Benchmark

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, orders with 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

RuleFormStatementTimeWorst write waitWorst read waitLockWaited
BV002ADD COLUMN NOT NULL without defaultFiresADD COLUMN ... NOT NULL, no defaultrefused after 4 ms3 ms2 msnone seen
SafeADD COLUMN ... NOT NULL DEFAULT 'none'4 ms2 ms1 msAccessExclusiveLock
BV003Non-concurrent index creationFiresCREATE INDEX6.4 s6.3 s15 msShareLock421×
SafeCREATE INDEX CONCURRENTLY7.4 s15 ms8 msShareUpdateExclusiveLock
BV004Column type change forcing a table rewriteFiresALTER COLUMN customer_id TYPE bigint (rewrite)11.4 s11.2 s11.2 sAccessExclusiveLock, ShareLock, rewrite5623×
Safeexpand step: ADD COLUMN customer_id_new bigint3 ms2 ms2 msAccessExclusiveLock
BV007Volatile column default forcing a table rewriteFiresADD COLUMN ... DEFAULT clock_timestamp() (volatile)15.4 s15.2 s15.2 sAccessExclusiveLock, ShareLock, rewrite5075×
SafeADD COLUMN ... DEFAULT now() (stable)5 ms3 ms2 msAccessExclusiveLock
BV008Foreign key added without NOT VALIDFiresADD CONSTRAINT ... FOREIGN KEY (validates under lock)10.8 s10.8 s4 msAccessShareLock, ShareRowExclusiveLock1195×
SafeNOT VALID, then VALIDATE CONSTRAINT7.9 s9 ms3 msAccessShareLock, ShareRowExclusiveLock, ShareUpdateExclusiveLock
BV009CHECK constraint added without NOT VALIDFiresADD CONSTRAINT ... CHECK (scans under lock)1.0 s973 ms972 msAccessExclusiveLock487×
SafeNOT VALID, then VALIDATE CONSTRAINT1.0 s2 ms1 msShareUpdateExclusiveLock
BV011SET NOT NULL scanning the table under ACCESS EXCLUSIVEFiresSET NOT NULL (full scan under ACCESS EXCLUSIVE)1.0 s954 ms954 msAccessExclusiveLock954×
SafeCHECK ... IS NOT NULL NOT VALID, VALIDATE, then SET NOT NULL691 ms1 ms1 msShareUpdateExclusiveLock
BV012PRIMARY KEY or UNIQUE constraint building its index under full lockFiresADD CONSTRAINT ... UNIQUE (index built under lock)3.8 s3.8 s3.8 sAccessExclusiveLock, ShareLock21.1×
SafeCREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX4.8 s179 ms1 msShareUpdateExclusiveLock
BV015VACUUM FULL / CLUSTER / REINDEX rewriting under full lockFiresVACUUM FULL (rewrite under ACCESS EXCLUSIVE)9.0 s9.0 s9.0 sAccessExclusiveLock, ShareLock, rewrite101×
SafeVACUUM (ANALYZE), no rewrite2.8 s89 ms9 msShareUpdateExclusiveLock
BV016DROP INDEX without CONCURRENTLYFiresDROP INDEX14 ms2 ms2 msAccessExclusiveLock
SafeDROP INDEX CONCURRENTLY11 ms2 ms2 msShareUpdateExclusiveLock
BV020Partition attach/detach blocking the partition treeFiresATTACH PARTITION without a matching CHECK (scans the partition under lock)2.5 s2.4 s1.8 sAccessExclusiveLock10.1×
SafeCHECK proving the bound, added NOT VALID and validated, then ATTACH PARTITION1.1 s186 ms242 msShareUpdateExclusiveLock
BV021Materialized view refreshed without CONCURRENTLYFiresREFRESH MATERIALIZED VIEW (readers blocked)1.4 s7 ms1.4 sAccessExclusiveLock, ExclusiveLock, ShareLock, rewrite231×
SafeREFRESH MATERIALIZED VIEW CONCURRENTLY1.3 s6 ms2 msExclusiveLock
BV024Stored generated column added to an existing tableFiresADD COLUMN ... GENERATED ALWAYS AS (...) STORED (rewrite)18.3 s18.2 s18.2 sAccessExclusiveLock, ShareLock, rewrite9089×
SafeADD COLUMN total_cents bigint, computed by the application or a later backfill3 ms2 ms1 msAccessExclusiveLock
BV034Exclusive-lock DDL without a lock_timeout guardFiresALTER TABLE ... ADD COLUMN behind an idle transaction, no lock_timeout10.0 s10.0 s10.0 sAccessExclusiveLock5.1×
SafeSET lock_timeout = '2s', then the same ALTER TABLEgave up after 2.0 s2.0 s2.0 snone seen
BV045Database-wide REINDEX in a migrationFiresREINDEX TABLE (every index rebuilt under lock)10.3 s10.2 s10.2 sShareLock14.8×
SafeREINDEX TABLE CONCURRENTLY12.5 s688 ms12 msShareUpdateExclusiveLock

"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. 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:17-alpine
bun run bench -- --rule BV003          # one rule
bun run bench -- --image postgres:18-alpine

Disagree 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