The uncontrolled growth of the pg_wal directory in PostgreSQL databases is one of the most common infrastructure issues, leading to rapid disk space depletion and eventually a complete halt of database services. The Write-Ahead Logging (WAL) mechanism is a fundamental building block ensuring database consistency and crash recovery. However, misconfigured replication slots, long-running transactions, or faulty archiving processes can cause this directory to bloat.
In this guide, I share a 5-step cleanup and optimization methodology that I've personally applied in production environments on PostgreSQL 14+ versions, reducing disk usage from 92% to a safe 40% and automating WAL management.
What is WAL Bloat and Why Does It Occur?
Every write operation (INSERT, UPDATE, DELETE) in PostgreSQL is first written to shared buffers and simultaneously recorded in WAL buffers. These changes are then written to WAL files (in segments, typically 16 MB each, under the pg_wal directory) before being permanently written to disk. This ensures the "Durability" principle of ACID compliance.
Under normal circumstances, PostgreSQL deletes or recycles old WAL files that are no longer needed as checkpoint processes complete. However, the database engine cannot safely delete these files if any of the following conditions exist:
- Inactive Replication Slots: The standby server disconnects, and WAL segments continue to accumulate.
- Long-running Transactions: Sessions that remain open for hours in an
idle in transactionstate. - Faulty Archiving Commands (
archive_command): WAL files fail to transfer to remote storage (S3, NAS, etc.) and continuously error out. - Misconfigured
max_wal_sizeParameter: Limits set too high relative to traffic intensity.
As I mentioned in my article on [related: PostgreSQL Index Strategies], any uncontrolled growth at the database layer directly impacts disk I/O performance. With WAL bloat, not only is disk space exhausted, but checkpoint times also increase, and recovery durations grow exponentially.
Step 1 - Diagnosing Bloat in the pg_wal Directory
Before fixing the problem, we need to get a clear picture of the current situation. On a production server, the first step is to check disk usage percentages and determine the actual size of the pg_wal directory. After checking disk usage with df -h in the Linux terminal, I navigate to the PostgreSQL data directory and measure the size of the WAL folder.
# Find the PostgreSQL data directory and measure pg_wal size
sudo -u postgres psql -t -A -c "SHOW data_directory;"
# Example output: /var/lib/postgresql/15/main
sudo du -sh /var/lib/postgresql/15/main/pg_wal
# Example output: 248G /var/lib/postgresql/15/main/pg_wal
If the pg_wal directory exceeds 200 GB and occupies more than 80% of your total disk space, you are at the stage of urgent intervention. To analyze the situation in more detail, we query PostgreSQL's internal system catalogs. The following SQL query helps us understand the current WAL generation rate and the amount of pending WAL data:
SELECT
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/00000000'::pg_lsn)) AS total_wal_produced,
count(*) AS wal_file_count,
pg_size_pretty(count(*) * 16 * 1024 * 1024) AS wal_directory_size
FROM pg_ls_dir('pg_wal') AS file
WHERE file ~ '^[0-9A-F]{24}$';
This query precisely tells us how many 16 MB WAL segments are actually present and their total size. If disk usage is at a critical level (98%+), the database might enter PANIC mode and shut down. At this stage, never manually run rm -rf pg_wal/*; this will cause irreversible database corruption.
Step 2 - Cleaning Up Replication Slots and Orphaned Transactions
A common cause of WAL accumulation is unused or disconnected replication slots. As long as a replication slot is active, PostgreSQL will not delete any WAL files that the slot has not yet consumed. When a standby server crashes or is taken down for maintenance, the primary server continues to keep the replication slot open, and WAL files start to pile up.
First, I run the following query to identify inactive slots that are accumulating WAL in the background:
SELECT
slot_name,
plugin,
slot_type,
active,
safe_wal_size,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal_bytes
FROM pg_replication_slots;
If the query result shows a slot with active set to f (false) and gigabytes of data accumulated in the retained_wal_bytes column, your failover or replication mechanism has broken.
⚠️ Critical Intervention
Deleting a replication slot that is inactive and no longer needed will allow PostgreSQL to automatically clean up (or recycle) the accumulated WAL files during the next checkpoint process.
To delete a broken replication slot, we use the following command:
-- Remove the inactive slot named 'replica_standby_node_1'
SELECT pg_drop_replication_slot('replica_standby_node_1');
Another significant factor is long-running transactions. A transaction that remains open for more than 2 hours prevents the deletion of all WAL files generated from that point onwards. To find these "orphaned" transactions, I run this query:
SELECT
pid,
age(clock_timestamp(), query_start),
state,
query
FROM pg_stat_activity
WHERE state != 'idle' AND age(clock_timestamp(), query_start) > interval '1 hour'
ORDER BY 2 DESC;
If there's a forgotten reporting query or a blocked write operation running in the background, I terminate the relevant process using pg_terminate_backend(pid), clearing the way for disk space to be freed.
Step 3 - Optimizing max_wal_size and min_wal_size Parameters
The WAL size parameters in PostgreSQL's configuration file (postgresql.conf) directly determine how many WAL files the system will retain on disk. Default configurations are often not optimized for modern hardware and high-traffic systems.
-
max_wal_size: This is the maximum size WAL can reach between checkpoints. It's not a hard limit, but when this limit is exceeded, PostgreSQL triggers an emergency checkpoint (soft checkpoint) and begins cleaning up old WAL files on disk. -
min_wal_size: This is the minimum WAL pool size. Even when the system is idle, this amount of WAL files is kept ready on disk for future write operations.
Here are the optimization settings I applied in a production ERP system with a high write load, which have proven stable:
# Add to /etc/postgresql/15/main/postgresql.conf or postgresql.auto.conf
# We limit the maximum WAL size to 16 GB (default is usually 1GB)
max_wal_size = 16GB
# We set the minimum WAL size to 2 GB (to prevent I/O fluctuations)
min_wal_size = 2GB
# We increase the checkpoint interval to 15 minutes (reduces I/O load)
checkpoint_timeout = 15min
# We set the checkpoint completion target to 90% (ensures I/O spreading)
checkpoint_completion_target = 0.9
After applying these settings, a reload of the PostgreSQL service is sufficient:
sudo systemctl reload postgresql
If you set max_wal_size too low, the system will constantly trigger checkpoints, leading to disk write (I/O) bottlenecks. If you set it too high, too much WAL can accumulate on disk instantaneously. Values between 16 GB and 32 GB offer an ideal balance for medium to large-scale databases with 1 TB of disk space.
Step 4 - Addressing Archive Command and WAL Archiving Bottlenecks
If your database has Point-In-Time Recovery (PITR) or Continuous Archiving enabled, archive_mode is set to on, and the archive_command parameter is used to copy WAL files to external storage.
If your archiving command (e.g., an rsync script, S3 upload tool, or pgBackRest) returns an error code other than exit status 0, PostgreSQL assumes the WAL file was not backed up safely and keeps that file waiting under pg_wal/archive_status until it is successfully archived. This situation can lead to hundreds of gigabytes of WAL accumulating within hours.
To check the archiving status, I run the following query:
SELECT * FROM pg_stat_archiver;
If the query result shows last_failed_time with a recent date and failed_count is continuously increasing, the archiving pipeline is blocked. Examining the logs, we typically see errors like this:
2026-05-20 14:22:10.431 UTC [12045] LOG: archive command failed with exit code 1
2026-05-20 14:22:10.431 UTC [12045] DETAIL: The failed command was: pgbackrest --stanza=db_main archive-push pg_wal/00000001000000A20000004F
In such cases, to temporarily bypass the archiving command or relieve the database until the issue is resolved, you can temporarily replace the archive_command with a dummy command (/bin/true). However, be aware: This action will break your recovery chain.
-- Only for emergencies, to make PostgreSQL consider archiving successful:
ALTER SYSTEM SET archive_command = '/bin/true';
SELECT pg_reload_conf();
After resolving the archiving issue (e.g., by clearing the target disk or fixing network connectivity), you must restore the original command and return the system to its normal state.
Step 5 - pg_archivecleanup and Automated Maintenance Processes
To prevent WAL files from accumulating on standby (Read-Replica) servers, we use the pg_archivecleanup utility that comes with PostgreSQL. This tool automatically cleans up old WAL files that the standby server no longer needs for its recovery process.
By integrating pg_archivecleanup into the standby server's recovery.conf (or restore_command within postgresql.conf for PostgreSQL 12+), this process is automated:
# Standby server configuration
restore_command = 'cp /var/lib/postgresql/archive/%f %p'
archive_cleanup_command = 'pg_archivecleanup /var/lib/postgresql/archive %r'
Here, the %r parameter represents the last successful checkpoint position. pg_archivecleanup safely deletes all WAL files preceding this point from the archive folder.
In the table below, I've summarized the functions of critical parameters used in WAL management and their recommended starting values:
| Parameter | Function | Recommended Production Value | Impact Level |
|---|---|---|---|
wal_keep_size |
Minimum WAL size to retain on primary for standby |
4GB to 8GB
|
Medium |
max_slot_wal_keep_size |
Maximum WAL size a replication slot can accumulate |
32GB (prevents disk explosion) |
Very High |
archive_timeout |
Forces WAL archiving at specified intervals |
600s (10 minutes) |
Low |
ℹ️ Lifesaving Parameter
The
max_slot_wal_keep_sizeparameter, introduced with PostgreSQL 13, is a lifesaver. If you set this parameter to, for example,64GB, the replication slot will accumulate at most 64 GB of WAL, even if the standby server disconnects. Once this limit is exceeded, the slot automatically enters a "lost" state, preventing the primary database's disk from filling up.
I always define this parameter in my own systems. A disconnected replication and a lagging standby are always more acceptable than the entire primary database crashing due to disk fullness.
Conclusion and Career Perspective: The Place of Database Management in Senior Engineering
In my career as a system architect or senior software engineer, I've seen that even the best code struggles on a poorly configured database infrastructure. Operational issues like pg_wal directory bloat are not just the responsibility of system administrators (SysAdmin/DevOps) but also of the developers writing the applications that interact with that database.
Avoiding long-running transactions, optimizing database connections (connection pool), and closely monitoring replication metrics are among the most concrete skills that define an engineer's seniority. Making your infrastructure observable and periodically checking the 5 steps above will save you from those dreaded midnight emergency calls.
As a next step, to further enhance your database performance, you can explore [related: PostgreSQL connection pool tuning] processes and optimize your connection limits.
Top comments (0)