An update creates a new PostgreSQL row version, and a delete makes an existing version invisible to later snapshots. Neither operation means every older transaction has finished using that version. Ordinary vacuum, including work started by autovacuum, must respect that visibility horizon before reclaiming storage. Scheduling more cleanup cannot override a reader’s legitimate snapshot.
When it breaks
A long-lived snapshot can keep old versions needed even after their deleting transaction commits. Vacuum may run successfully while reclaiming less than expected. Lowering an autovacuum threshold will not make those versions removable. Investigate old transactions and other horizon holders, such as an xmin retained by a replication slot, before assuming the background worker is simply too slow.
Reclaiming a tuple’s space usually makes it reusable inside the same relation. It does not necessarily reduce the file’s length. Ordinary vacuum can truncate empty pages at the end, but it does not move surviving rows out of the middle to compact every hole. A stable file size after cleanup can therefore be healthy reuse rather than evidence that nothing happened.
Check it quickly
Use two fresh psql -X -v ON_ERROR_STOP=1 sessions, A and B, on the same disposable PostgreSQL 18 database. This exercise temporarily disables autovacuum only on its fixture, then invokes ordinary vacuum manually to control the timing. It demonstrates the visibility rule shared with automatic vacuum; it does not claim to measure the automatic scheduler. B creates five thousand rows:
CREATE SCHEMA vacuum_demo;
CREATE TABLE vacuum_demo.items (id integer PRIMARY KEY,payload text)
WITH (autovacuum_enabled=false);
INSERT INTO vacuum_demo.items
SELECT g,repeat(md5(g::text),10) FROM generate_series(1,5000) AS g;
SELECT pg_stat_force_next_flush();
VACUUM (ANALYZE) vacuum_demo.items;
CREATE TABLE vacuum_demo.baseline AS
SELECT pg_relation_size('vacuum_demo.items') AS heap_bytes;
The statistics-flush helper publishes pending counters at the next opportunity; here it keeps laboratory observations from lagging behind the commands. The baseline records heap-file bytes, excluding indexes and other forks. In A, establish a Repeatable Read snapshot and leave it open:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM vacuum_demo.items;
A sees five thousand rows. In B, delete the first four thousand, publish the write counters, and vacuum:
DELETE FROM vacuum_demo.items WHERE id<=4000;
SELECT pg_stat_force_next_flush();
VACUUM (VERBOSE) vacuum_demo.items;
SELECT pg_stat_force_next_flush();
SELECT count(*) FROM vacuum_demo.items;
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relid='vacuum_demo.items'::regclass;
B sees one thousand live rows, while this isolated run reports four thousand dead tuples still retained. The statistics view is an estimate, not an exact count of physical bytes. In A, read again before ending its transaction:
SELECT count(*) FROM vacuum_demo.items;
COMMIT;
A still sees all five thousand rows. This is the reason vacuum cannot simply erase the older versions: an existing snapshot can still read them. After A commits, B repeats cleanup:
VACUUM (VERBOSE) vacuum_demo.items;
SELECT pg_stat_force_next_flush();
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relid='vacuum_demo.items'::regclass;
SELECT pg_relation_size('vacuum_demo.items')=heap_bytes AS same_heap_size
FROM vacuum_demo.baseline;
The dead-tuple estimate falls to zero, while the heap-size comparison remains true. Surviving rows occupy pages near the original end, so clearing earlier space does not shorten this file. No snapshot now needs the deleted rows; the unchanged size is not a reason to delete the database file or force a rewrite.
B inserts four thousand new rows and checks whether the reclaimed space is reused:
INSERT INTO vacuum_demo.items
SELECT g,repeat(md5(g::text),10) FROM generate_series(5001,9000) AS g;
SELECT count(*),min(id),max(id) FROM vacuum_demo.items;
SELECT pg_relation_size('vacuum_demo.items')=heap_bytes AS reused_existing_heap
FROM vacuum_demo.baseline;
DROP SCHEMA vacuum_demo CASCADE;
Expect five thousand rows with identifiers 4001 through 9000, and a true size comparison. In this fixture, replacement rows fit in existing heap space without growing the relation. Different row widths, concurrent activity, and free-space distribution can change that size result in a real workload. The example finally drops its schema, including the table-level autovacuum override.
Observe removal, reuse, and eligibility separately
n_dead_tup estimates obsolete tuples; last_autovacuum records an automatic run. Neither alone proves that a given amount of disk capacity was returned. Compare retained versions, relation growth, cleanup duration, and the workload’s latency. Distinguish manual vacuum timestamps from automatic ones, and account for delayed cumulative-statistics publication when observing short experiments.
Autovacuum also maintains visibility information and protects against transaction-ID wraparound. Insert-only tables can need vacuum even without an update/delete backlog. Disabling routine work is therefore not a safe long-term answer to maintenance load. Conversely, a quiet table with an old automatic-vacuum timestamp is not automatically broken. Check its eligibility, table overrides, worker availability, and blocking activity. Tune thresholds or capacity only after identifying which part prevents timely cleanup; retained snapshots and insufficient worker throughput call for different corrections.
Sources
- PostgreSQL 18 routine vacuuming, version removal, space reuse, visibility maintenance, and freezing, checked 2026-09-10.
- PostgreSQL 18 cumulative statistics, estimated tuple counts and publication timing, checked 2026-09-10.
- PostgreSQL 18 statistics function implementation, the explicit statistics-flush helper used by the experiment, checked 2026-09-10.