Home Database PostgreSQL Performance Tuning: Memory, I/O, and Vacuum
Advanced 3 min · July 13, 2026

PostgreSQL Performance Tuning: Memory, I/O, and Vacuum

Master PostgreSQL performance tuning: optimize memory, I/O, and autovacuum.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Production
production tested
July 13, 2026
last updated
2,165
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of SQL and PostgreSQL
  • Access to a PostgreSQL instance (local or cloud) with superuser privileges
  • Familiarity with EXPLAIN and pg_stat_* views
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Tune shared_buffers to 25% of RAM for dedicated servers.
  • Configure effective_cache_size to 50-75% of RAM for query planning.
  • Use work_mem per query (not per session) to avoid OOM.
  • Set autovacuum thresholds to prevent bloat and transaction ID wraparound.
  • Monitor I/O with pg_stat_bgwriter and pg_stat_user_tables.
✦ Definition~90s read
What is PostgreSQL Performance Tuning?

PostgreSQL performance tuning is the process of adjusting memory, I/O, and vacuum settings to optimize database throughput and latency for your workload.

Think of PostgreSQL as a library.
Plain-English First

Think of PostgreSQL as a library. Memory tuning is like deciding how many bookshelves (shared_buffers) and how big the reading tables (work_mem) are. I/O tuning is organizing the books so you find them fast. Vacuum is like cleaning up returned books and removing old sticky notes so the library stays tidy and fast.

PostgreSQL is renowned for its reliability and advanced features, but out-of-the-box configuration is often too conservative for production workloads. Performance tuning is essential to unlock its full potential. This article dives into three critical areas: memory allocation, I/O optimization, and vacuum management. You'll learn how to configure PostgreSQL to handle high concurrency, large datasets, and write-heavy loads without grinding to a halt. We'll cover real-world scenarios, debugging techniques, and common pitfalls. By the end, you'll be able to diagnose performance bottlenecks and apply targeted fixes. Whether you're running a small web app or a data warehouse, these tuning principles will help you get the most out of PostgreSQL.

1. Memory Configuration: Shared Buffers and Beyond

PostgreSQL uses several memory areas. The most critical is shared_buffers, which caches data blocks. Set it to 25% of RAM for a dedicated server. effective_cache_size tells the planner how much cache is available (including OS cache); set it to 50-75% of RAM. work_mem controls memory for sorts and hash tables; set per query (not per session) to avoid OOM. maintenance_work_mem is for VACUUM and CREATE INDEX; increase it to speed up maintenance. Example: For a 32GB RAM server, set shared_buffers=8GB, effective_cache_size=24GB, work_mem=64MB (per query), maintenance_work_mem=1GB.

memory_config.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Check current settings
SHOW shared_buffers;
SHOW effective_cache_size;
SHOW work_mem;
SHOW maintenance_work_mem;

-- Recommended settings for 32GB RAM (in postgresql.conf)
-- shared_buffers = 8GB
-- effective_cache_size = 24GB
-- work_mem = 64MB
-- maintenance_work_mem = 1GB

-- To apply without restart (requires superuser)
ALTER SYSTEM SET shared_buffers = '8GB';
ALTER SYSTEM SET effective_cache_size = '24GB';
ALTER SYSTEM SET work_mem = '64MB';
ALTER SYSTEM SET maintenance_work_mem = '1GB';
SELECT pg_reload_conf();
Output
Settings applied. Check with SHOW.
⚠ Don't Over-allocate shared_buffers
📊 Production Insight
In a production environment with many concurrent connections, reducing work_mem per query is safer. Use EXPLAIN (ANALYZE, BUFFERS) to see if sorts use disk.
🎯 Key Takeaway
Set shared_buffers to 25% of RAM, effective_cache_size to 75% of RAM, and tune work_mem per query.

2. I/O Tuning: Checkpoints and WAL

PostgreSQL writes data to WAL (Write-Ahead Log) and flushes to data files during checkpoints. Checkpoint frequency is controlled by max_wal_size, min_wal_size, and checkpoint_completion_target. Increase max_wal_size to reduce checkpoint frequency (but increases recovery time). Set checkpoint_completion_target to 0.9 to spread writes. Use pg_stat_bgwriter to monitor checkpoints. Also tune wal_buffers (default 16MB is often enough). For write-heavy workloads, consider using SSDs and increasing checkpoint intervals.

io_tuning.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- Check checkpoint stats
SELECT * FROM pg_stat_bgwriter;

-- Current WAL settings
SHOW max_wal_size;
SHOW min_wal_size;
SHOW checkpoint_completion_target;

-- Recommended for write-heavy: increase max_wal_size
ALTER SYSTEM SET max_wal_size = '4GB';
ALTER SYSTEM SET min_wal_size = '1GB';
ALTER SYSTEM SET checkpoint_completion_target = 0.9;
SELECT pg_reload_conf();
Output
Checkpoints will be less frequent, reducing I/O spikes.
💡Monitor Checkpoint Frequency
📊 Production Insight
On SSDs, you can set checkpoint_completion_target higher (0.95) because random writes are fast. On HDDs, keep it lower to avoid I/O storms.
🎯 Key Takeaway
Increase max_wal_size and set checkpoint_completion_target to 0.9 to smooth I/O.

3. Autovacuum: Preventing Bloat and Transaction ID Wraparound

Autovacuum cleans up dead tuples and prevents transaction ID wraparound. It's controlled by autovacuum_vacuum_threshold, autovacuum_vacuum_scale_factor, and autovacuum_naptime. For large tables, reduce scale_factor to 0.01 and increase threshold to 1000. Monitor with pg_stat_user_tables. Use pgstattuple extension to measure bloat. For write-heavy tables, consider manual VACUUM during low traffic. Also set autovacuum_freeze_max_age to avoid wraparound (default 200 million).

vacuum_tuning.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Check autovacuum settings
SHOW autovacuum_vacuum_threshold;
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_naptime;

-- Per-table settings for a large table
ALTER TABLE orders SET (autovacuum_vacuum_threshold = 1000);
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01);

-- Check dead tuples
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'orders';

-- Measure bloat
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('orders');
Output
Bloat percentage = (dead_tuple_len / tuple_len) * 100.
🔥Transaction ID Wraparound
📊 Production Insight
In a high-write environment, set autovacuum_naptime to 1s (default 1min) to react faster, but watch CPU usage.
🎯 Key Takeaway
Tune autovacuum per table for write-heavy workloads and monitor bloat regularly.

4. Query Planning: Effective Cache and Statistics

The query planner uses effective_cache_size to estimate if index scans are cheaper than sequential scans. Set it correctly to encourage index usage. Also, keep statistics up-to-date with default_statistics_target (default 100). Increase it for columns with skewed data. Use ANALYZE after bulk loads. Monitor with EXPLAIN (ANALYZE, BUFFERS) to see actual vs estimated rows.

query_planning.sqlSQL
1
2
3
4
5
6
7
8
9
-- Check effective_cache_size
SHOW effective_cache_size;

-- Increase statistics target for a column
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;

-- Analyze a query plan
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending';
Output
Look for 'rows' vs 'actual rows' mismatch. If large, increase statistics target.
💡Use pg_stat_statements
📊 Production Insight
After major data changes, run ANALYZE to update statistics. Autovacuum does this automatically but may lag.
🎯 Key Takeaway
Set effective_cache_size correctly and increase statistics target for skewed data.

5. Indexing Strategies for Performance

Indexes speed up reads but slow writes. Use B-tree for equality and range queries, GIN for full-text and arrays, GiST for geometric data. Avoid over-indexing; use pg_stat_user_indexes to find unused indexes. Consider partial indexes for common WHERE clauses. Use covering indexes (INCLUDE columns) to avoid heap fetches. Monitor index bloat with pgstattuple.

index_strategies.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
-- Find unused indexes
SELECT schemaname, tablename, indexname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0;

-- Create a partial index
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending';

-- Create a covering index
CREATE INDEX idx_orders_covering ON orders (status) INCLUDE (created_at, total);

-- Check index bloat
SELECT * FROM pgstattuple('idx_orders_pending');
Output
Unused indexes waste space and slow writes. Drop them.
⚠ Index Maintenance
📊 Production Insight
In high-write environments, consider delaying index creation until after bulk loads, or use REINDEX during maintenance windows.
🎯 Key Takeaway
Use partial and covering indexes to optimize queries, and remove unused indexes.

6. Connection Pooling and Resource Limits

PostgreSQL forks a process per connection, so too many connections waste memory. Use a connection pooler like PgBouncer to limit active connections. Set max_connections to a reasonable number (e.g., 100). Each connection uses work_mem, so reducing connections saves memory. Also tune max_worker_processes for parallel queries. Monitor with pg_stat_activity.

connection_pooling.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Check current connections
SELECT count(*) FROM pg_stat_activity;

-- Set max_connections (requires restart)
ALTER SYSTEM SET max_connections = '100';

-- For PgBouncer, configure pool_mode = transaction
-- Example pgbouncer.ini:
-- [databases]
-- mydb = host=localhost port=5432 dbname=mydb
-- [pgbouncer]
-- pool_mode = transaction
-- max_client_conn = 200
-- default_pool_size = 20
Output
Connection pooling reduces memory usage and improves scalability.
🔥PgBouncer Transaction Mode
📊 Production Insight
Each idle connection still uses memory. Set idle_in_transaction_session_timeout to kill stuck transactions.
🎯 Key Takeaway
Use a connection pooler and limit max_connections to conserve memory.

7. Monitoring and Continuous Tuning

Performance tuning is iterative. Use pg_stat_statements to track query performance over time. Set up alerts for high I/O, long queries, and bloat. Use pgBadger for log analysis. Regularly review configuration with tools like pgtune. Document changes and test in staging. Remember that hardware upgrades (SSD, more RAM) can complement tuning.

monitoring.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
-- Enable pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top 5 queries by total time
SELECT query, total_time, calls, rows FROM pg_stat_statements ORDER BY total_time DESC LIMIT 5;

-- Check vacuum progress
SELECT * FROM pg_stat_progress_vacuum;

-- Check table bloat (using pgstattuple)
SELECT relname, (dead_tuple_len::numeric / tuple_len * 100) AS bloat_pct FROM pg_stat_user_tables, pgstattuple(relname::text) WHERE relname = 'orders';
Output
Use these queries to identify performance bottlenecks.
💡Automate Monitoring
📊 Production Insight
After a major deployment, re-evaluate your configuration. Workloads change over time.
🎯 Key Takeaway
Continuously monitor and adjust settings based on workload patterns.
● Production incidentPOST-MORTEMseverity: high

The Midnight Autovacuum Storm

Symptom
Queries became extremely slow between 2 AM and 3 AM, with some timing out. CPU and I/O usage spiked to 100%.
Assumption
The developer assumed it was a cron job or backup causing the load.
Root cause
Autovacuum was set to aggressive thresholds, causing it to constantly vacuum a large, frequently updated table, consuming all I/O bandwidth.
Fix
Adjusted autovacuum_vacuum_scale_factor from 0.2 to 0.01 and autovacuum_vacuum_threshold from 50 to 1000 for that table, and scheduled vacuum during low traffic.
Key lesson
  • Always tune autovacuum per table for write-heavy workloads.
  • Monitor autovacuum activity with pg_stat_progress_vacuum.
  • Set autovacuum_naptime to avoid constant vacuuming.
  • Use pg_stat_user_tables to track bloat and vacuum frequency.
  • Test configuration changes in staging before production.
Production debug guideSymptom to Action4 entries
Symptom · 01
Slow queries, high CPU
Fix
Check pg_stat_activity for long-running queries and pg_stat_statements for high total_time.
Symptom · 02
High I/O wait
Fix
Examine pg_stat_bgwriter for checkpoints and pg_stat_user_tables for seq scans.
Symptom · 03
Table bloat, slow index scans
Fix
Run pgstattuple to measure bloat and trigger VACUUM or VACUUM FULL.
Symptom · 04
Transaction ID wraparound risk
Fix
Check age() of datfrozenxid in pg_database and force vacuum.
★ Quick Debug Cheat SheetImmediate actions for common performance issues.
High CPU, slow queries
Immediate action
Identify top queries with pg_stat_statements
Commands
SELECT query, total_time FROM pg_stat_statements ORDER BY total_time DESC LIMIT 5;
EXPLAIN (ANALYZE, BUFFERS) <query>;
Fix now
Add missing index or rewrite query.
High I/O wait+
Immediate action
Check checkpoint frequency
Commands
SELECT * FROM pg_stat_bgwriter;
SHOW checkpoint_completion_target;
Fix now
Increase checkpoint intervals or spread writes.
Table bloat+
Immediate action
Measure bloat with pgstattuple
Commands
SELECT * FROM pgstattuple('table_name');
SELECT schemaname, tablename, n_dead_tup FROM pg_stat_user_tables;
Fix now
Run VACUUM or VACUUM FULL.
Transaction ID wraparound+
Immediate action
Check database age
Commands
SELECT datname, age(datfrozenxid) FROM pg_database;
SELECT relname, age(relfrozenxid) FROM pg_class;
Fix now
Force VACUUM FREEZE on old databases.
ParameterDefaultRecommended (32GB RAM)Description
shared_buffers128MB8GBCache for data pages
effective_cache_size4GB24GBPlanner's estimate of cache size
work_mem4MB64MBMemory per query operation
maintenance_work_mem64MB1GBMemory for VACUUM, CREATE INDEX
max_wal_size1GB4GBMaximum WAL size before checkpoint
checkpoint_completion_target0.50.9Spread checkpoint writes over time
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
memory_config.sqlSHOW shared_buffers;1. Memory Configuration
io_tuning.sqlSELECT * FROM pg_stat_bgwriter;2. I/O Tuning
vacuum_tuning.sqlSHOW autovacuum_vacuum_threshold;3. Autovacuum
query_planning.sqlSHOW effective_cache_size;4. Query Planning
index_strategies.sqlSELECT schemaname, tablename, indexname, idx_scan FROM pg_stat_user_indexes WHER...5. Indexing Strategies for Performance
connection_pooling.sqlSELECT count(*) FROM pg_stat_activity;6. Connection Pooling and Resource Limits
monitoring.sqlCREATE EXTENSION IF NOT EXISTS pg_stat_statements;7. Monitoring and Continuous Tuning

Key takeaways

1
Memory tuning
shared_buffers (25% RAM), effective_cache_size (50-75% RAM), work_mem per query.
2
I/O tuning
increase max_wal_size, set checkpoint_completion_target to 0.9.
3
Autovacuum
tune per table for write-heavy workloads, monitor bloat and transaction age.
4
Indexing
use partial and covering indexes, remove unused indexes.
5
Connection pooling
use PgBouncer to limit active connections and save memory.
6
Continuous monitoring
use pg_stat_statements and pg_stat_progress_vacuum.

Common mistakes to avoid

3 patterns
×

Setting shared_buffers too high (e.g., 80% of RAM)

×

Disabling autovacuum to improve performance

×

Using VACUUM FULL frequently

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the purpose of shared_buffers and how you would size it.
Q02JUNIOR
What is autovacuum and why is it important?
Q03SENIOR
How would you debug a sudden spike in I/O wait on PostgreSQL?
Q01 of 03SENIOR

Explain the purpose of shared_buffers and how you would size it.

ANSWER
shared_buffers is PostgreSQL's cache for data pages. Size it to 25% of RAM on a dedicated server. It reduces disk reads by caching frequently accessed data.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the optimal shared_buffers size?
02
How do I know if autovacuum is running?
03
What is transaction ID wraparound and how to prevent it?
04
Should I use VACUUM FULL or VACUUM?
05
How can I reduce I/O spikes from checkpoints?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Verified
production tested
July 13, 2026
last updated
2,165
articles · all by Naren
🔥

That's MySQL & PostgreSQL. Mark it forged?

3 min read · try the examples if you haven't

Previous
pgvector: Vector Search and Embeddings in PostgreSQL
17 / 20 · MySQL & PostgreSQL
Next
PostgreSQL 17 and 18: New Features and Migration