PostgreSQL is an exceptional database engine, but its out-of-the-box configuration is intentionally conservative — designed to run on a minimal system without exhausting resources, not to extract maximum performance from a production server. Most performance guides correctly point engineers toward query optimization and index design. Fewer address the two configuration areas that cause the most unexplained, creeping degradation in production PostgreSQL deployments: autovacuum tuning and connection management. Get these two wrong and no amount of index work will fully compensate.
This guide focuses on practical, tested configuration changes for both areas, along with the memory parameters that interact most directly with them. We avoid invented benchmarks; all figures cited represent established behavior documented in PostgreSQL's official documentation and its associated tooling.
Understanding Table Bloat: Why Autovacuum Is Not Optional
PostgreSQL uses a storage model called MVCC (Multi-Version Concurrency Control). When a row is updated or deleted, the old version is not immediately removed from disk — it is marked as obsolete and left in place. New transactions can still see the old version if they started before the change. This is what allows PostgreSQL to serve reads without locking out writes.
The tradeoff is that old row versions — called dead tuples — accumulate in heap files. Over time, in any write-active table, this bloat causes several problems:
- Sequential scans read more pages than the live data requires, wasting I/O and cache space.
- Indexes continue to reference dead tuple locations, slowing index lookups.
- Eventually, without cleanup, PostgreSQL risks transaction ID wraparound — a catastrophic condition where the database can no longer distinguish old from new data and is forced into a full emergency vacuum that may cause minutes or hours of downtime.
VACUUM is the process that reclaims dead tuple space and updates visibility maps. Autovacuum is the background daemon that triggers VACUUM automatically. When it is poorly tuned, it either runs too infrequently (allowing severe bloat to build) or too aggressively (causing I/O spikes that degrade query latency).
Tuning Autovacuum for Production Write Workloads
The default autovacuum parameters assume a lightly loaded system. In production with significant write traffic, several parameters need adjustment.
Triggering thresholds: Autovacuum fires on a table when dead tuples exceed autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × table_row_count. The defaults are 50 rows and 20% of the table respectively. For a table with 10 million rows, this means autovacuum only triggers after 2 million dead tuples accumulate — far too late for a busy OLTP table. A more aggressive configuration:
# In postgresql.conf autovacuum_vacuum_scale_factor = 0.01 # trigger at 1% dead tuples autovacuum_analyze_scale_factor = 0.01 # keep statistics fresh
For your most critical tables, you can override these settings at the table level without touching the global config:
ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.005, autovacuum_vacuum_threshold = 100 );
Cost delay throttling: By default, autovacuum is deliberately throttled to avoid impacting foreground queries. The key parameter is autovacuum_vacuum_cost_delay, which defaults to 2ms on modern PostgreSQL versions. On SSDs with low-latency random I/O, this throttling is often unnecessary. Setting it to 0 or 1ms allows autovacuum to run faster and finish before bloat accumulates, at the cost of marginally more I/O during vacuum runs.
Worker count: autovacuum_max_workers defaults to 3. In an environment with dozens of frequently-written tables, consider raising this to 5 or 6 to allow more parallel cleaning. Each worker uses maintenance_work_mem of memory, so account for that when sizing.
| Parameter | Default | Recommended (write-heavy) | Why Change |
|---|---|---|---|
autovacuum_vacuum_scale_factor |
0.20 | 0.01 – 0.05 | Trigger sooner on large tables |
autovacuum_analyze_scale_factor |
0.10 | 0.01 – 0.05 | Keep query planner statistics current |
autovacuum_vacuum_cost_delay |
2ms | 0 – 1ms (SSD) | Remove artificial throttle on fast storage |
autovacuum_max_workers |
3 | 5 – 6 | Parallelise across many active tables |
maintenance_work_mem |
64MB | 256MB – 1GB | Faster vacuum index cleanup |
Monitoring bloat: To see how well autovacuum is keeping up, query pg_stat_user_tables:
SELECT relname, n_dead_tup, n_live_tup, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20;
Tables where n_dead_tup persistently exceeds 10–20% of n_live_tup, or where last_autovacuum is days old, need per-table autovacuum tuning.
Connection Pooling: Why max_connections Is the Wrong Lever
PostgreSQL spawns a dedicated OS process for every client connection. This design is robust and crash-tolerant, but it has a real cost: each process occupies roughly 5–10MB of memory at baseline, plus its work_mem allocation for sort and hash operations. At 500 connections with work_mem = 32MB, you have committed up to 16GB of RAM to query working memory alone — before any data is even buffered.
The typical response to connection exhaustion is to raise max_connections. This is almost always the wrong answer. Higher max_connections means more memory committed, more lock contention, and a less effective shared buffer cache divided among more processes. The correct answer is a connection pooler sitting between your application and PostgreSQL.
PgBouncer is the most widely deployed option. It is lightweight (a single C process), battle-tested in production deployments handling hundreds of thousands of transactions per second, and easy to configure. PgBouncer maintains a small pool of actual PostgreSQL connections and multiplexes application connections across them.
PgBouncer operates in three modes:
- Session mode: One server connection per client session. Simple, but provides no multiplexing benefit for applications that hold idle connections.
- Transaction mode: A server connection is borrowed from the pool only for the duration of a transaction, then released. This is where the major multiplexing benefit is realized — 100 application connections can share 10–20 PostgreSQL server connections if transactions are short. This is the recommended mode for most web application workloads.
- Statement mode: Connection released after each statement. Incompatible with multi-statement transactions; rarely used.
A minimal PgBouncer configuration for transaction-mode pooling looks like this:
[databases] myapp = host=127.0.0.1 port=5432 dbname=myapp [pgbouncer] listen_port = 6432 listen_addr = 127.0.0.1 auth_type = scram-sha-256 pool_mode = transaction max_client_conn = 500 default_pool_size = 20 min_pool_size = 5 server_idle_timeout = 600
max_connections at 100–200 and use PgBouncer in transaction mode to serve hundreds of simultaneous application connections. This combination uses far less RAM and reduces lock contention compared to raising max_connections alone.Memory Parameters That Interact Directly With Autovacuum and Pooling
Several memory settings need to be calibrated alongside the autovacuum and pooling changes above:
shared_buffers: PostgreSQL's internal cache of data pages. The standard recommendation is 25% of total RAM. Going higher is rarely beneficial because the operating system's page cache also holds frequently read pages, and the OS cache is generally more efficient for sequential reads. On a 32GB server, set shared_buffers = 8GB.
work_mem: Memory per sort or hash operation, per query. With connection pooling keeping actual PostgreSQL connections low (e.g., 20 server connections via PgBouncer), you can afford a more generous work_mem (e.g., 64–128MB) without the memory explosion that would occur with 500 raw connections. This directly benefits complex queries with sorts, hash joins, and hash aggregates.
effective_cache_size: Not an allocation — this is a hint to the query planner about how much memory is available for caching (shared_buffers plus the OS page cache combined). Set it to 50–75% of total RAM. Underestimating this value causes the planner to prefer sequential scans over index scans unnecessarily.
random_page_cost: The planner's cost estimate for a random page read. The default of 4.0 is calibrated for spinning hard disks. On SSDs, set this to 1.1–2.0 to encourage index usage, which has minimal seek penalty on solid-state storage.
Write Throughput Configuration
For write-heavy workloads, these parameters significantly affect throughput:
wal_buffers: Buffer for WAL (Write-Ahead Log) data before it is flushed to disk. The default of -1 sets it to 1/32nd of shared_buffers, capped at 16MB. In most cases simply setting wal_buffers = 16MB is appropriate and removes the dependency on the scaling formula.
checkpoint_completion_target: Controls how long PostgreSQL spreads the I/O work of a checkpoint across. The default of 0.9 means it tries to complete the checkpoint within 90% of the checkpoint interval. This is already a reasonable value for most workloads and should not be lowered.
synchronous_commit: Setting this to off or local allows PostgreSQL to return success to the client before the WAL record is flushed to disk. This dramatically improves write throughput (removing the disk flush latency from the write path) at the cost of potentially losing the last fraction of a second of committed transactions in a crash. This tradeoff is acceptable for many workloads — analytics ingestion, logging, or session state — but never for financial or transactional data where losing a commit is unacceptable.
Monitoring the Results
After applying configuration changes, always verify improvement with metrics rather than intuition.
For autovacuum effectiveness:
SELECT schemaname, relname, n_dead_tup, last_autovacuum, autovacuum_count FROM pg_stat_user_tables WHERE n_dead_tup > 1000 ORDER BY n_dead_tup DESC;
For buffer cache hit rate (target above 99% for an OLTP workload):
SELECT sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS hit_ratio FROM pg_statio_user_tables;
For connection state via PgBouncer, the SHOW POOLS command inside the PgBouncer admin console shows live clients, waiting clients, and active server connections — the most direct view of whether pooling is keeping up with demand.
Frequently Asked Questions
How do I know if table bloat is hurting query performance right now?
Run SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) AS total, pg_size_pretty(pg_relation_size(oid)) AS heap FROM pg_class WHERE relkind='r' ORDER BY pg_total_relation_size(oid) DESC LIMIT 20 and compare table size against your expected data volume. You can also use the pgstattuple extension for a detailed dead tuple percentage breakdown. If a table is two to three times larger than the data it holds, bloat is likely a factor.
Can I use both PgBouncer and a high max_connections setting together?
Technically yes, but it defeats the purpose. If you have PgBouncer limiting server connections to 20–50, raising max_connections to 1000 gains you nothing and still allocates the memory overhead for those potential connections at startup. The right pattern is PgBouncer in front with max_connections kept modest (100–200) to preserve memory for caches and per-query working memory.
Is there a risk to lowering autovacuum_vacuum_cost_delay to 0?
On systems with heavy concurrent write traffic and spinning disks, removing the cost delay can cause autovacuum I/O spikes that compete with application queries for disk bandwidth. On modern NVMe or SSD storage, the risk is much lower because the storage subsystem handles concurrent I/O without the seek penalty that makes disk contention severe. Monitor I/O utilization with iostat -x 1 after the change and restore a small delay (1–2ms) if you observe elevated latency in application queries during vacuum runs.
The Bottom Line
PostgreSQL's out-of-the-box defaults will keep a small system stable, but they will quietly undermine performance on any production workload at scale. We recommend treating autovacuum configuration and connection pooling as first-class infrastructure concerns, revisited at each significant change in write volume or connection concurrency. The changes are low-risk, reversible, and consistently deliver more measurable improvement than adding another round of index tuning to an already-indexed schema. Start with the monitoring queries above to establish your baseline, apply the autovacuum scale factor and PgBouncer changes, and measure again within a week. The difference is usually substantial.
Sources & References:
PostgreSQL Official Documentation — Resource Consumption and Autovacuum Configuration (postgresql.org/docs)
PostgreSQL Official Documentation — Server Configuration: Connection Settings (postgresql.org/docs)
PgBouncer Official Documentation — Configuration Reference (pgbouncer.org/config.html)
Disclaimer: This article is for informational purposes only. Technology landscapes change rapidly; verify information with official sources before making technical decisions.