SyncTrix logoSyncTrix
All articles
Platform10 min read

The database is running out of disk and deleting rows did not help

Postgres does not return space to the operating system when you delete rows. Understanding dead tuples, bloat and what actually reclaims space prevents an avoidable outage.

By Priya Iyer
The database is running out of disk and deleting rows did not help

Disk usage is at ninety percent and climbing. Someone deleted several million old rows and the free space did not change. This surprises people the first time, but it is expected behaviour: in Postgres a delete marks rows as dead rather than removing them, and reclaiming the space is a separate operation with real trade-offs.

01Deletes create dead tuples, not free space

Postgres uses multi-version concurrency control, so an update writes a new row version and a delete marks the existing version as no longer visible. Transactions already running may still need the old version, so the space cannot be released immediately. What you get after a large delete is a table the same size on disk containing a great deal of dead space.

Autovacuum marks that space reusable for future inserts in the same table, which stops growth but does not shrink the file. The operating system continues to see the original allocation, which is why the disk graph does not move.

OperationEffectCost
AutovacuumMarks space reusable in-tableRuns continuously, low impact
VACUUM FULLRewrites table, returns space to OSExclusive lock - table unavailable
pg_repackSame result, no long exclusive lockNeeds free space and an extension
TRUNCATEInstantly frees the whole tableRemoves all rows
DROP partitionInstantly frees that partitionRequires partitioned design
What reclaims space, and what it costs

02VACUUM FULL is not the answer during business hours

VACUUM FULL rewrites the table and does return space to the operating system, but it takes an access exclusive lock for the duration. On a large table that means the table is unreadable and unwritable for minutes to hours, which converts a disk warning into a self-inflicted outage.

It also requires roughly as much free space as the table occupies, because it writes a new copy before removing the old one. Running it on a nearly full disk can fail partway and leave you worse off than before.

03Find the bloat before choosing a fix

Measure which tables and indexes are actually bloated rather than assuming. Compare the on-disk size against the live tuple count, and check the dead tuple counts in pg_stat_user_tables. A table that is ninety percent dead space is worth reorganising; one at ten percent is not worth the disruption.

Indexes bloat independently and are frequently the larger problem, particularly under heavy update workloads. Rebuilding an index concurrently is far less disruptive than rewriting the table and often recovers most of the space.

CauseSignature
Long-running transactionAutovacuum cannot remove recent dead rows
Abandoned replication slotWAL accumulates without bound
High update rate on a wide tableTable and index bloat grow together
Autovacuum too conservativeDead tuples climb steadily
Unarchived WALpg_wal directory grows until disk is full
Common causes of unexpected growth

04Check for a stuck replication slot

An inactive replication slot prevents Postgres from discarding write-ahead log segments, because it assumes a replica will eventually need them. A replica that was decommissioned without dropping its slot will fill the disk with WAL indefinitely, and this is one of the more common causes of sudden unexplained growth.

Look at pg_replication_slots for slots marked inactive and drop any that correspond to systems no longer in use. A long-running transaction has a similar effect on vacuum and is worth checking at the same time.

05Design so that deletion is cheap

For time-series and log-style data, partitioning by time converts deletion into dropping a partition, which is effectively instantaneous and returns space immediately. This is a far better long-term position than periodically wrestling with vacuum on a single enormous table.

Combine that with retention decided when the table is created. Data kept because nobody chose a retention period tends to be kept forever, and the cost is paid in storage, backup duration and every restore you ever perform.

Topics

postgres disk fulldeleted rows disk space not freedpostgres table bloatvacuum full disk spacedatabase running out of storage

Priya Iyer

Staff Engineer · SyncTrix

Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.

Building something like this?

SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.

Talk to an engineer