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
| Rule | Form | Statement | Time | Worst write wait | Worst read wait | Lock | Waited |
|---|---|---|---|---|---|---|---|
BV002ADD COLUMN NOT NULL without default | Fires | ADD COLUMN ... NOT NULL, no default | refused after 4 ms | 3 ms | 2 ms | none seen | |
| Safe | ADD COLUMN ... NOT NULL DEFAULT 'none' | 4 ms | 2 ms | 1 ms | AccessExclusiveLock | ||
BV003Non-concurrent index creation | Fires | CREATE INDEX | 6.4 s | 6.3 s | 15 ms | ShareLock | 421× |
| Safe | CREATE INDEX CONCURRENTLY | 7.4 s | 15 ms | 8 ms | ShareUpdateExclusiveLock | ||
BV004Column type change forcing a table rewrite | Fires | ALTER COLUMN customer_id TYPE bigint (rewrite) | 11.4 s | 11.2 s | 11.2 s | AccessExclusiveLock, ShareLock, rewrite | 5623× |
| Safe | expand step: ADD COLUMN customer_id_new bigint | 3 ms | 2 ms | 2 ms | AccessExclusiveLock | ||
BV007Volatile column default forcing a table rewrite | Fires | ADD COLUMN ... DEFAULT clock_timestamp() (volatile) | 15.4 s | 15.2 s | 15.2 s | AccessExclusiveLock, ShareLock, rewrite | 5075× |
| Safe | ADD COLUMN ... DEFAULT now() (stable) | 5 ms | 3 ms | 2 ms | AccessExclusiveLock | ||
BV008Foreign key added without NOT VALID | Fires | ADD CONSTRAINT ... FOREIGN KEY (validates under lock) | 10.8 s | 10.8 s | 4 ms | AccessShareLock, ShareRowExclusiveLock | 1195× |
| Safe | NOT VALID, then VALIDATE CONSTRAINT | 7.9 s | 9 ms | 3 ms | AccessShareLock, ShareRowExclusiveLock, ShareUpdateExclusiveLock | ||
BV009CHECK constraint added without NOT VALID | Fires | ADD CONSTRAINT ... CHECK (scans under lock) | 1.0 s | 973 ms | 972 ms | AccessExclusiveLock | 487× |
| Safe | NOT VALID, then VALIDATE CONSTRAINT | 1.0 s | 2 ms | 1 ms | ShareUpdateExclusiveLock | ||
BV011SET NOT NULL scanning the table under ACCESS EXCLUSIVE | Fires | SET NOT NULL (full scan under ACCESS EXCLUSIVE) | 1.0 s | 954 ms | 954 ms | AccessExclusiveLock | 954× |
| Safe | CHECK ... IS NOT NULL NOT VALID, VALIDATE, then SET NOT NULL | 691 ms | 1 ms | 1 ms | ShareUpdateExclusiveLock | ||
BV012PRIMARY KEY or UNIQUE constraint building its index under full lock | Fires | ADD CONSTRAINT ... UNIQUE (index built under lock) | 3.8 s | 3.8 s | 3.8 s | AccessExclusiveLock, ShareLock | 21.1× |
| Safe | CREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX | 4.8 s | 179 ms | 1 ms | ShareUpdateExclusiveLock | ||
BV015VACUUM FULL / CLUSTER / REINDEX rewriting under full lock | Fires | VACUUM FULL (rewrite under ACCESS EXCLUSIVE) | 9.0 s | 9.0 s | 9.0 s | AccessExclusiveLock, ShareLock, rewrite | 101× |
| Safe | VACUUM (ANALYZE), no rewrite | 2.8 s | 89 ms | 9 ms | ShareUpdateExclusiveLock | ||
BV016DROP INDEX without CONCURRENTLY | Fires | DROP INDEX | 14 ms | 2 ms | 2 ms | AccessExclusiveLock | |
| Safe | DROP INDEX CONCURRENTLY | 11 ms | 2 ms | 2 ms | ShareUpdateExclusiveLock | ||
BV020Partition attach/detach blocking the partition tree | Fires | ATTACH PARTITION without a matching CHECK (scans the partition under lock) | 2.5 s | 2.4 s | 1.8 s | AccessExclusiveLock | 10.1× |
| Safe | CHECK proving the bound, added NOT VALID and validated, then ATTACH PARTITION | 1.1 s | 186 ms | 242 ms | ShareUpdateExclusiveLock | ||
BV021Materialized view refreshed without CONCURRENTLY | Fires | REFRESH MATERIALIZED VIEW (readers blocked) | 1.4 s | 7 ms | 1.4 s | AccessExclusiveLock, ExclusiveLock, ShareLock, rewrite | 231× |
| Safe | REFRESH MATERIALIZED VIEW CONCURRENTLY | 1.3 s | 6 ms | 2 ms | ExclusiveLock | ||
BV024Stored generated column added to an existing table | Fires | ADD COLUMN ... GENERATED ALWAYS AS (...) STORED (rewrite) | 18.3 s | 18.2 s | 18.2 s | AccessExclusiveLock, ShareLock, rewrite | 9089× |
| Safe | ADD COLUMN total_cents bigint, computed by the application or a later backfill | 3 ms | 2 ms | 1 ms | AccessExclusiveLock | ||
BV034Exclusive-lock DDL without a lock_timeout guard | Fires | ALTER TABLE ... ADD COLUMN behind an idle transaction, no lock_timeout | 10.0 s | 10.0 s | 10.0 s | AccessExclusiveLock | 5.1× |
| Safe | SET lock_timeout = '2s', then the same ALTER TABLE | gave up after 2.0 s | 2.0 s | 2.0 s | none seen | ||
BV045Database-wide REINDEX in a migration | Fires | REINDEX TABLE (every index rebuilt under lock) | 10.3 s | 10.2 s | 10.2 s | ShareLock | 14.8× |
| Safe | REINDEX TABLE CONCURRENTLY | 12.5 s | 688 ms | 12 ms | ShareUpdateExclusiveLock |
"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-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