pg-vacuum-and-bloat
Skill1 starsChanged 28 days agoMITView on GitHub
Use when a PostgreSQL table or index keeps growing, designing update- or delete-heavy tables, writing mass DELETE or UPDATE jobs, or someone proposes VACUUM FULL. Key rules: never disable autovacuum, tune it per hot table; an open or idle-in-transaction session stops vacuum from removing any dead row, so set idle_in_transaction_session_timeout; fillfactor below 100 and no index on hot-updated columns for HOT updates; delete in batches; never VACUUM FULL a live table.
- Deletes or force-pushes
In this repository22 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- new-skillSkill
- review-skillSkill
- db-auditSkill
- db-connection-budgetSkill
- db-explainSkill
- mysql-connectionsSkill
- mysql-indexingSkill
- mysql-keys-and-typesSkill
- mysql-online-ddlSkill
- mysql-query-patternsSkill
- mysql-throughput-per-connectionSkill
- mysql-transactions-and-lockingSkill
- pg-config-by-sizeSkill
- pg-connectionsSkill
- pg-indexingSkill
- pg-keys-and-typesSkill
- pg-observabilitySkill
- pg-online-migrationsSkill
- pg-query-patternsSkill
- pg-throughput-per-connectionSkill
- pg-transactions-and-lockingSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 16
Never disable autovacuum, globally or with autovacuum_enabled = false on a table.
- Very specific: line 18
Keep transactions short and set idle_in_transaction_session_timeout (and transaction_timeout, PG 17+), because any open transaction holds …
- Very specific: line 20
Run large DELETE and UPDATE jobs in bounded batches with a commit per batch, and purge time-based data by dropping or detaching partitions …
- Very specific: line 21
Never run VACUUM FULL, CLUSTER or a plain REINDEX on a live table.
- Distinctive: line 110
When reviewing schema or SQL under this skill, confirm:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Vacuum and bloat
The decision
Every UPDATE and DELETE in PostgreSQL leaves the old row version behind as a dead tuple. Only VACUUM makes that space reusable, and it can only remove tuples that no open transaction might still see. The decision is how you keep that cleanup ahead of the write rate: leave autovacuum on and tune it per hot table, keep transactions short so vacuum can do its job, design hot tables so updates are HOT, and never fix bloat with a lock that takes the table offline. Getting it wrong costs disk, cache and I/O on every scan of a table that is mostly dead space, index bloat from non-HOT updates, outages from VACUUM FULL, and in the worst case a database that refuses new transactions to avoid XID wraparound.
The rule
- Never disable autovacuum, globally or with
autovacuum_enabled = falseon a table; tune it instead. - On large or hot tables, set per-table
autovacuum_vacuum_scale_factorandautovacuum_vacuum_thresholdwithALTER TABLE ... SET (...), and on PG 18+ lowerautovacuum_vacuum_max_thresholdwhere the 100,000,000 default is too lazy. - Keep transactions short and set
idle_in_transaction_session_timeout(andtransaction_timeout, PG 17+), because any open transaction holds back the xmin horizon and VACUUM cannot remove tuples that died after it started. - Design update-heavy tables for HOT updates:
fillfactorbelow 100 and no index on the columns you update on every request, then checkn_tup_hot_updagainstn_tup_upd. - Run large
DELETEandUPDATEjobs in bounded batches with a commit per batch, and purge time-based data by dropping or detaching partitions instead of a massDELETE. - Never run
VACUUM FULL,CLUSTERor a plainREINDEXon a live table; the first two takeACCESS EXCLUSIVEfor the whole rewrite and need disk for a full second copy,REINDEXblocks writes until the rebuild ends, andREINDEX CONCURRENTLYis the online form. - Measure bloat with
pgstattuple(exact) orpgstattuple_approx(cheaper), not with estimation formulas. - Monitor
age(datfrozenxid)andage(relfrozenxid)againstautovacuum_freeze_max_age(default 200 million) and alert long before the server's own wraparound warnings.
Why
Autovacuum off does not stop the work, it delays it. The docs say "It is unwise to disable the daemon completely unless you have an extremely predictable workload", and anti-wraparound vacuums run "even if autovacuum is nominally disabled", so turning it off only means the vacuum that eventually runs is a forced, larger one. A table with autovacuum_enabled = false "will not be autovacuumed, except to prevent transaction ID wraparound" (routine vacuuming, storage parameters). AWS says the same for RDS: "We strongly recommend that you use the autovacuum feature" (RDS autovacuum).
The default trigger scales with table size. Autovacuum vacuums a table when dead tuples exceed Minimum(vacuum max threshold, vacuum base threshold + vacuum scale factor * number of tuples). The defaults are 50 + 20% of reltuples, and on PG 18 a cap of 100,000,000 (routine vacuuming, vacuum settings). For a 50-million-row table that is 10 million dead rows before the first vacuum. All three settings can be overridden per table, and the good example shows them landing in pg_class.reloptions. Before PG 18 there is no cap, so the per-table scale factor is the only lever. The values in the example (0.01 and 1000) are a starting point to adjust while you watch n_dead_tup, not a number from the docs.
An open transaction makes dead tuples unremovable. "An open transaction prevents vacuuming away recently-dead tuples that may be visible only to this transaction; so remaining idle for a long time can contribute to table bloat" (idle_in_transaction_session_timeout). The horizon is set by the oldest backend_xid or backend_xmin in pg_stat_activity, plus replication slots and prepared transactions. The bad example opens one idle transaction in a second session; VACUUM (VERBOSE) then reports 0 removed ... 2000 are dead but not yet removable on both PG 15 and 18, and pgstattuple still counts 2000 dead tuples. After ROLLBACK, the same VACUUM removes all 2000. Both timeouts default to 0 (disabled). transaction_timeout was added in PG 17 (release 17).
HOT updates skip the indexes and clean themselves up. An update is HOT when it "does not modify any columns referenced by the table's indexes, not including summarizing indexes" (BRIN) and "there is sufficient free space on the page containing the old row". Then no new index entries are written, and old versions in the chain can be pruned during normal operation, including SELECTs, without waiting for vacuum (HOT). fillfactor (default 100, range 10 to 100) reserves free space on each page for exactly this (fillfactor). In the examples the same 2,000 updates are 2,000 HOT updates with fillfactor = 90 and the hot column unindexed, and 0 HOT updates when that column has an index. An index you add "just in case" on a counter or status column costs you every HOT update on that table.
One giant transaction is one giant pile of dead tuples. A single DELETE of millions of rows creates all of its dead tuples at once, and none can be removed until it commits; meanwhile it is itself the oldest transaction holding the horizon for every other table in the database. Batches with a commit each let autovacuum reclaim space as you go and keep each transaction short. For time-based retention, "dropping an individual partition using DROP TABLE, or doing ALTER TABLE DETACH PARTITION, is far faster than a bulk operation" and "entirely avoid[s] the VACUUM overhead caused by a bulk DELETE" (partitioning).
Plain VACUUM is online; VACUUM FULL is not. Plain VACUUM "can operate in parallel with normal reading and writing of the table, as an exclusive lock is not obtained"; the space is kept for reuse in the same table, not returned to the OS (except empty pages at the end). VACUUM FULL "rewrites the entire contents of the table into a new disk file", "requires an ACCESS EXCLUSIVE lock", needs extra disk for the new copy, and is "not recommended for routine use" (VACUUM). ACCESS EXCLUSIVE blocks even SELECT. The bad example measures it on PG 15 and 18: with a report holding ACCESS SHARE on page_counters, a VACUUM FULL from another session queues for ACCESS EXCLUSIVE, a plain SELECT ... WHERE id = 1 that arrives after it fails with 55P03, and the table has a new filenode when it finishes. CLUSTER does the same rewrite under the same lock (pg_locks shows ACCESS EXCLUSIVE plus the index rebuild's SHARE until commit). A plain REINDEX holds SHARE on the table, so writes wait for the whole rebuild, and ACCESS EXCLUSIVE on the index, so reads that use it wait too. For bloated indexes use REINDEX CONCURRENTLY (REINDEX).
Measure, do not estimate. pgstattuple "always performs a full-table scan and returns an exact count of live and dead tuples (and their sizes) and free space", under only a read lock. pgstattuple_approx skips all-visible pages using the visibility map and gives exact dead-tuple counts with estimated live tuples and free space. Both need superuser or the pg_stat_scan_tables role (pgstattuple). It is a contrib extension shipped in the official Docker image.
Wraparound is the failure that stops writes. XIDs are 32-bit. Autovacuum forces an aggressive vacuum on any table older than autovacuum_freeze_max_age (default 200 million), even with autovacuum disabled. The server warns at 40 million transactions from wraparound and "will refuse to assign new XIDs once there are fewer than three million transactions left" (preventing XID wraparound). The same xmin holders that cause bloat (long transactions, abandoned replication slots, prepared transactions) also block freezing. On RDS, rds.adaptive_autovacuum (on by default) makes autovacuum more aggressive once MaximumUsedTransactionIDs reaches autovacuum_freeze_max_age or 500,000,000, whichever is greater, but AWS still says to alarm on wraparound (RDS autovacuum).
Version notes. PG 17 "allow[s] vacuum to more efficiently store tuple references", and vacuum "is no longer silently limited to one gigabyte of memory when maintenance_work_mem or autovacuum_work_mem are higher" (release 17). On PG 16 and older, a vacuum with more dead tuples than fits in 1 GB (about 179 million, per AWS) has to scan the indexes more than once. PG 18 adds autovacuum_vacuum_max_threshold (vacuum settings).
Good example
See examples/good/test.sql.
CREATE TABLE page_counters (
id bigint PRIMARY KEY,
path text NOT NULL,
hits bigint NOT NULL DEFAULT 0
) WITH (fillfactor = 90);
ALTER TABLE page_counters SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 1000
);
-- Update every 10th row, touching only the non-indexed column.
UPDATE page_counters SET hits = hits + 1 WHERE id % 10 = 0;
-- Statistics are flushed asynchronously; force it (PG 15+).
SELECT pg_stat_force_next_flush();
-- Plain VACUUM: no ACCESS EXCLUSIVE lock, reads and writes keep running.
VACUUM page_counters;The test asserts that the storage parameters are in pg_class.reloptions (plus autovacuum_vacuum_max_threshold on PG 18), that all 2,000 updates are HOT (n_tup_hot_upd = n_tup_upd), that pgstattuple shows dead tuples before VACUUM and zero after, that a purge of 10,000 rows in batches of 2,000 with a commit each ends with only live rows after VACUUM, and that age(datfrozenxid) is below autovacuum_freeze_max_age.
Bad example
See examples/bad/test.sql.
CREATE INDEX page_counters_hits_idx ON page_counters (hits);
SELECT dblink_connect('holder', 'dbname=' || current_database());
SELECT dblink_exec('holder', 'BEGIN');
SELECT * FROM dblink('holder', 'SELECT txid_current()') AS t(xid bigint);
-- MISTAKE: updating an indexed column. No update can be HOT, so every
-- one adds index entries.
UPDATE page_counters SET hits = hits + 1 WHERE id % 10 = 0;
-- MISTAKE: running VACUUM and assuming the space is reclaimed. The open
-- transaction is older than the dead tuples, so they are "dead but not
-- yet removable" and VACUUM keeps every one of them.
VACUUM page_counters;SELECT dblink_send_query('maintenance', 'VACUUM FULL page_counters');-- MISTAKE: CLUSTER as a "faster VACUUM FULL": the same rewrite under the
-- same lock, held until the transaction commits.
BEGIN;
SELECT pg_relation_filenode('page_counters') AS node_before \gset
CLUSTER page_counters USING page_counters_pkey;The test asserts that the second session is idle in transaction with an XID, that 0 of the 2,000 updates are HOT, and that after VACUUM pgstattuple still counts at least 2,000 dead tuples. It then rolls the second session back and shows the same VACUUM removing them all, so it fails if the open transaction stops holding the horizon. It then shows the lock-based "fixes": a VACUUM FULL queued behind a report blocks a plain SELECT (55P03) and leaves the table with a new filenode; CLUSTER holds ACCESS EXCLUSIVE (plus SHARE) and rewrites the table; a plain REINDEX holds SHARE on the table and ACCESS EXCLUSIVE on the index.
Checklist
When reviewing schema or SQL under this skill, confirm:
- No
autovacuum = offand noautovacuum_enabled = falseon any table. - Large or update-heavy tables have per-table
autovacuum_vacuum_scale_factor/autovacuum_vacuum_threshold(and on PG 18+,autovacuum_vacuum_max_thresholdwhere needed), visible inpg_class.reloptions. idle_in_transaction_session_timeoutis set for application roles, and nothing (job, migration, report, open psql) keeps a transaction open for minutes;pg_stat_activityshows no oldbackend_xmin.- Update-heavy tables have
fillfactorbelow 100 and no index on a column changed by every update, andn_tup_hot_upd / n_tup_updis checked. - Mass
DELETE/UPDATEjobs run in batches with a commit per batch, or retention usesDROP/DETACH PARTITION. - No
VACUUM FULL,CLUSTERor plainREINDEXon a table that serves traffic; bloated indexes useREINDEX CONCURRENTLY. - Bloat claims come from
pgstattupleorpgstattuple_approx, not an estimate query. - Alerts exist on
age(datfrozenxid)againstautovacuum_freeze_max_age, and on replication slots and prepared transactions that hold back the horizon.
When not to apply
- Append-only tables that are never updated or deleted (logs, events): keep
fillfactorat 100, which the docs call "the best choice" for them, and leave the update-oriented autovacuum tuning alone. Insert-driven vacuums still run for freezing and the visibility map. - A table that has to shrink on disk after most of its rows were deleted and no online rewrite is available:
VACUUM FULLis acceptable in a maintenance window where theACCESS EXCLUSIVElock and the second copy's disk are planned for. - A one-off bulk load into a new table nobody reads yet: disabling autovacuum on it for the load is fine if you run
VACUUM (ANALYZE)right after and turn it back on. - Indexes on a frequently updated column that queries really need: keep the index and accept non-HOT updates, but then tune autovacuum on that table more aggressively.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

