Optimize PostgreSQL for production. Covers query optimization, indexing strategies, EXPLAIN ANALYZE, connection pooling, partitioning, vacuum tuning, and monitoring for high-traffic applications.
PostgreSQL Performance Engineering is the practice of diagnosing, tuning, and maintaining PostgreSQL databases for predictable, efficient, and scalable operation. It matters when queries slow down, connections pile up, backups take hours, or the database becomes a bottleneck under load—especially in systems where response time or data consistency is critical.
Optimizing queries begins with identifying slow ones. Use EXPLAIN ANALYZE to get real execution plans and timing:
EXPLAIN (ANALYZE, BUFFERS, COSTS, TIMING, SUMMARY)
SELECT u.id, u.name, COUNT(t.id) AS task_count
FROM users u
JOIN tasks t ON u.id = t.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id, u.name
ORDER BY task_count DESC
LIMIT 10;
The output includes actual row counts, elapsed times, and buffer usage. Look for:
Heap Fetches—indicates inefficient row lookups.Create targeted indexes with CREATE INDEX, using INCLUDE to reduce Heap Fetches:
CREATE INDEX idx_tasks_user_status ON tasks (user_id, status)
INCLUDE (created_at, priority, title);
Use pg_stat_statements to track query performance over time. Enable it in postgresql.conf:
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 50000
pg_stat_statements.track = 'all'
pg_stat_statements.track_utility = on
Query the stats table:
SELECT
query,
calls,
total_time,
mean_time,
rows,
100.0 * (total_time / sum(total_time) OVER()) AS percent_total
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 10;
Common pitfalls:
INCLUDE columns are not NOT NULL or when data is updated. CREATE INDEX idx_tasks_active ON tasks (user_id)
WHERE status = 'active';
enable_bitmapscan = on and sufficient work_mem.Adjust postgresql.conf based on workload and hardware. Key parameters:
shared_buffers = 8GB
effective_cache_size = 32GB
work_mem = 4MB
maintenance_work_mem = 2GB
# Concurrency
max_connections = 200
effective_io_concurrency = 16
random_page_cost = 2.0
# Checkpoints
checkpoint_completion_target = 0.9
checkpoint_timeout = 30min
min_wal_size = 4GB
max_wal_size = 16GB
# Write-ahead log
wal_buffers = 16MB
synchronous_commit = on
fsync = on
Use pg_settings to verify current values and detect changes:
SELECT name, setting, boot_val, reset_val, source
FROM pg_settings
WHERE name IN (
'shared_buffers', 'work_mem', 'checkpoint_completion_target', 'synchronous_commit'
)
ORDER BY name;
Critical failure modes:
checkpoint_completion_target < 0.5 leads to long checkpoint periods with increased I/O pressure.effective_cache_size too low causes seq scan to be misestimated, leading to poor plan choices.work_mem too small results in frequent sorts spilling to disk.synchronous_commit = on with fsync = off causes data loss on crash—common in cloud environments.PostgreSQL handles connections efficiently, but connection overhead can dominate under load. Monitor pg_stat_activity:
SELECT
datname,
usename,
client_addr,
state,
query,
now() - query_start AS duration
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY duration DESC;
Use pg_sleep to test connection behavior:
SELECT pg_sleep(10);
-- In another session:
SELECT * FROM pg_stat_activity WHERE query = 'SELECT pg_sleep(10);';
Tune postgresql.conf for high-concurrency scenarios:
max_connections = 500
superuser_reserved_connections = 10
default_transaction_isolation = 'read committed'
default_transaction_read_only = on
Key connection-level settings:
idle_in_transaction_session_timeout prevents idle transactions from consuming resources.statement_timeout limits long-running queries.lock_timeout prevents deadlocks from blocking for minutes.Failure modes:
idle_in_transaction_session_timeout not set causes long-lived idle transactions, leading to transaction ID wraparound.default_transaction_read_only = on improves performance, but requires clients to explicitly set READ WRITE when needed.max_connections not tuned leads to connection starvation and pg_hba.conf misconfiguration.Indexes degrade over time due to updates and deletions. Use pg_stat_user_indexes to identify underperforming indexes:
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
100.0 * idx_tup_fetch / idx_tup_read AS fetch_ratio
FROM pg_stat_user_indexes
ORDER BY idx_tup_fetch DESC;
High idx_tup_fetch relative to idx_tup_read indicates inefficient index use.
Schedule VACUUM and ANALYZE in a maintenance window:
# Weekly full vacuum and analyze
psql -c "VACUUM (VERBOSE, ANALYZE) TABLE users, tasks;"
Use pg_repack to rebuild tables with minimal downtime:
# Install pg_repack via apt or from source
pg_repack -d mydb -U postgres -t users -e
pg_repack uses TRIGGER-based replication and pg_stat_progress_repack to track progress:
SELECT
phase,
total_heap_blocks,
processed_heap_blocks,
total_index_blocks,
processed_index_blocks,
elapsed_time
FROM pg_stat_progress_repack
WHERE tablename = 'users';
Common index maintenance issues:
VACUUM not run regularly leads to bloat—tables grow beyond their logical size.ANALYZE not run after major data loads causes poor query plans.NOT NULL or when heap pages are updated but not VACUUMed.pg_repack fails if there are foreign key constraints or triggers not compatible with the repacking process.WAL is the backbone of durability and replication. Monitor WAL activity with pg_statio_user_tables and pg_wal_lsn_diff:
SELECT
schemaname,
relname,
seq_scan,
seq_tup_read,
idx_scan,
idx_tup_fetch,
n_tup_ins,
n_tup_upd,
n_tup_del,
n_tup_hot_upd
FROM pg_statio_user_tables
ORDER BY seq_tup_read DESC;
Check WAL write performance:
SELECT
name,
setting,
unit,
source,
context
FROM pg_settings
WHERE name LIKE 'wal%';
Key WAL settings:
wal_buffers = 16MB
wal_writer_delay = 200ms
wal_writer_flush_after = 1MB
wal_sync_method = fsync
Use pg_wal_lsn_diff to compute WAL generation rate:
SELECT
pg_wal_lsn_diff(pg_current_wal_lsn(), pg_last_wal_replay_lsn()) AS bytes_written;
Failure modes:
wal_writer_flush_after too large causes WAL write bursts and delayed checkpointing.wal_sync_method = fsync with fsync = off leads to data loss on crash.checkpoint_timeout too short causes checkpoint bloat and frequent I/O spikes.min_wal_size too small leads to frequent checkpoints and inefficient WAL reuse.Monitor database health with pg_stat_bgwriter and pg_stat_database:
SELECT
checkpoints_timed,
checkpoints_requested,
checkpoint_write_time,
checkpoint_sync_time,
buffers_checkpoint,
buffers_clean,
buffers_backend,
buffers_backend_fsync
FROM pg_stat_bgwriter;
Use pg_stat_database to track per-database activity:
SELECT
datname,
numbackends,
xact_commit,
xact_rollback,
blks_read,
blks_hit,
tup_returned,
tup_fetched,
tup_inserted,
tup_updated,
tup_deleted
FROM pg_stat_database
WHERE datname = 'myapp';
Use pg_stat_io to monitor I/O patterns:
SELECT
schemaname,
tablename,
seq_scan,
seq_tup_read,
idx_scan,
idx_tup_fetch,
idx_tup_read,
idx_tup_fetch
FROM pg_statio_user_tables;
Common diagnostic patterns:
blks_hit and blks_read indicate cache efficiency.tup_updated and tup_deleted suggest frequent writes.xact_commit much higher than xact_rollback indicates high transaction success rate.idx_tup_fetch > idx_tup_read points to inefficient index use.Enable parallel query execution:
max_parallel_workers_per_gather = 4
max_parallel_workers = 16
min_parallel_table_scan_size = 16MB
min_parallel_index_scan_size = 1MB
Use pg_stat_statements to identify parallel query candidates:
SELECT
query,
calls,
total_time,
rows,
100.0 * (total_time / sum(total_time) OVER()) AS percent_total
FROM pg_stat_statements
WHERE query LIKE '%SELECT%'
ORDER BY total_time DESC;
For large tables, use table partitioning:
CREATE TABLE sales (
id BIGSERIAL,
order_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (order_date);
CREATE TABLE sales_2023 PARTITION OF sales
FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
Enable autovacuum on partitions:
ALTER TABLE sales_2023 SET (
autovacuum_enabled = on,
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 1000
);
Failure modes:
min_parallel_table_scan_size too large.autovacuum not enabled on child tables leads to bloat and poor query performance.CREATE INDEX CONCURRENTLY fails when table is heavily updated during index build.shared_buffers too small or query planning not cached.VACUUM → maintenance_work_mem too small or autovacuum not running.checkpoint_completion_target but long checkpoint times → checkpoint_timeout too short.pg_sleep queries take longer than expected → idle_in_transaction_session_timeout not set.CREATE INDEX CONCURRENTLY fails → ALTER TABLE on table during index build.Use pg_stat_progress_create_index to monitor concurrent index creation:
SELECT
relid,
indexrelid,
phase,
current_block,
total_blocks,
elapsed_time
FROM pg_stat_progress_create_index;
PostgreSQL Performance Engineering is not a one-time task. It is continuous, driven by observation, measurement, and iteration—each tuning decision grounded in real SQL, real configuration, and real I/O.
This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy