warn-rewrite-disk-headroom
Full-table rewrite on a large table needs room for a second copy while it runs.
- operation
- Tables
- lock taken
- ACCESS EXCLUSIVE
- remediation
- Informational
- category
- Production context
- Rewrites the table
- Blocks reads
- Blocks writes
What triggers it
Whatever classifyRewrite() recognises as a full rewrite: a VacuumStmt carrying a full DefElem, a ClusterStmt, or an AlterTableStmt with an AT_AlterColumnType, AT_SetLogged, AT_SetUnLogged, or an AT_AddColumn whose default expression contains a volatile call (now, random, nextval, clock_timestamp, timeofday, gen_random_uuid, uuid_generate_v4, statement_timestamp) — or any default at all when pgVersion < 11. The table then has to be at least 1_000_000_000 bytes (1 GB) by TableStats.totalBytes. VACUUM and CLUSTER resolve stats through lookupTableStats() on the name in the statement, because the engine does not extract targets for them.
What does not
Anything classifyRewrite() does not recognise, including REINDEX, which is out of scope on purpose: it needs a second copy of the index rather than the table, and the table stats do not separate index size from total size, so the arithmetic would be wrong. Tables under 1 GB. Every run without --database-url, where no TableStats exists (requiresDatabaseUrl is set).
Where it applies
Applies to every PostgreSQL version MigrationPilot targets. It needs --database-url: without a connection it has nothing to read and stays silent.
Headroom numbers require a pg_tablespace_avail(name) function on the server. Core PostgreSQL has none as of PG18, so the normal output reports the sizes and hands the free-space check back to the operator.
The lock, and what it blocks
VACUUM FULL, CLUSTER, and every rewriting ALTER TABLE form in scope hold ACCESS EXCLUSIVE for the entire copy, blocking reads as well as writes. That is what makes running out of space expensive rather than merely annoying: the rollback arrives after the full lock duration has already been paid, and nothing has changed.
Why it matters
None of these operations edit the table in place. PostgreSQL builds a complete new copy, heap and indexes, and only drops the original once the new copy is committed — so peak usage is roughly twice the current size. On a 400 GB table that is 400 GB of free space you need to have and probably were not thinking about. Running out partway through does not corrupt anything, but the rewrite rolls back and you have paid the full ACCESS EXCLUSIVE lock duration for nothing.
Unsafe, and safe
Flagged
-- orders is 400 GB; the volume has 120 GB free VACUUM FULL orders;
Safe alternative
-- Price the second copy before starting, and check the volume with -- df -h $(psql -tAc "SHOW data_directory") SELECT pg_size_pretty(pg_total_relation_size('orders')) AS current_size, pg_size_pretty(pg_total_relation_size('orders') * 2) AS peak_during_rewrite;
What it assumes
Free space is normally unknown. Released PostgreSQL exposes no function for it, so ctx.cluster.disk.availableBytes is undefined on effectively every server and the message says so and defers to df -h on the data volume; real headroom numbers appear only where an operator has defined their own pg_tablespace_avail(name) returning bytes, which MigrationPilot feature-detects. Where the figure does exist, 1.5 times the required space is an arbitrary line between "tight" and "fine". The doubling estimate is coarse in its own right: it ignores the WAL the rewrite generates and any concurrent traffic, and totalBytes bundles heap, indexes and TOAST, which a rewrite does not reproduce in the same proportions.
This rule reads live catalogue state, so it says nothing at all without --database-url. That is the trade: no connection, no guess.
What the CLI prints
⚠ [MP102] WARNING (line 2)
VACUUM FULL rewrites "orders", which is 429.5 GB today (heap, indexes, and TOAST). The rewrite writes a full second copy before releasing the original, so peak usage is about 859.0 GB and roughly 429.5 GB of free space is needed. MigrationPilot cannot read free space from this server. PostgreSQL has no function for it. Check the data volume yourself (df -h on $PGDATA) before running this.
Safe alternative:
-- Check free space on the data volume first:
-- df -h $(psql -tAc "SHOW data_directory")
-- Confirm what the copy will cost:
SELECT pg_size_pretty(pg_total_relation_size('orders')) AS current_size,
pg_size_pretty(pg_total_relation_size('orders') * 2) AS peak_during_rewrite;
-- pg_repack rebuilds the table without the ACCESS EXCLUSIVE lock, but it still
-- needs the same second copy, so the disk requirement does not change:
-- pg_repack --table=orders --no-superuser-check <dbname>
Why: VACUUM FULL, CLUSTER, and a rewriting ALTER TABLE do not edit the table in place. PostgreSQL builds a complete new copy, heap and indexes, and only drops the original once the new copy is committed. Peak usage is therefore roughly twice the current size. If the volume fills up partway through, the rewrite fails and rolls back, and you have paid the full lock duration for nothing.
Docs: https://migrationpilot.dev/rules/mp102Generated by running the CLI's own formatter over the flagged example above, so it is the text the tool actually produces. A real run also reports the other rules that fire on the same statement; those blocks are left out here. The catalogue figures come from the production context this rule documents.
Turning it off
For one statement, put a comment on the line before it:
-- migrationpilot-disable MP102 VACUUM FULL orders;
For the whole project, in .migrationpilotrc.yml — by name or by id:
rules:
MP102: false
# or keep it, and downgrade it
rules:
MP102:
severity: warningTry it
Open this rule's flagged example in the playground. It runs in your browser — edit it and watch the finding appear and disappear.
Run MP102 in the playground