PostgreSQL Performance Tuning
Tune PostgreSQL 15/16 with pg_stat_statements for query visibility, smarter autovacuum thresholds, targeted indexing, and parallel query execution.
Before you start
- ▸PostgreSQL 13 or later installed and running via systemd
- ▸sudo or direct postgres OS user access
- ▸A test database with realistic data volume for meaningful profiling
- ▸Basic familiarity with psql and reading EXPLAIN output
A default PostgreSQL installation ships with conservative settings designed to run on modest hardware without breaking anything. In production, those defaults quietly strangle throughput. This guide walks through four high-impact tuning areas: query-level visibility with pg_stat_statements, keeping the heap clean with autovacuum, choosing the right indexes, and unlocking parallelism for analytical queries. Commands target PostgreSQL 15/16 on current LTS releases, though most settings apply back to PG 13.
Enable pg_stat_statements
pg_stat_statements is a core contrib module that tracks execution statistics for every normalized query. You cannot tune what you cannot measure, so this comes first.
Load the module
Edit postgresql.conf (location varies by distro) and add the module to the preload list.
# Debian/Ubuntu
sudo -u postgres psql -c "SHOW config_file;"
# Typically: /etc/postgresql/16/main/postgresql.conf
# Fedora/RHEL/Rocky
# Typically: /var/lib/pgsql/data/postgresql.conf
sudo sed -i "s/#shared_preload_libraries = ''/shared_preload_libraries = 'pg_stat_statements'/" \
/etc/postgresql/16/main/postgresql.conf
Then tune the module's own parameters in the same file:
cat >> /etc/postgresql/16/main/postgresql.conf << 'EOF'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
pg_stat_statements.track_utility = off
EOF
Restart the server (a reload is not enough for shared_preload_libraries):
sudo systemctl restart postgresql # Debian/Ubuntu
sudo systemctl restart postgresql-16 # Fedora/RHEL/Rocky
sudo systemctl restart postgresql # Arch
Activate the extension and query it
sudo -u postgres psql -d mydb -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
Find your top ten slowest queries by total execution time:
sudo -u postgres psql -d mydb << 'SQL'
SELECT
round(total_exec_time::numeric, 2) AS total_ms,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(stddev_exec_time::numeric,2) AS stddev_ms,
left(query, 80) AS query_snippet
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
SQL
Reset statistics after a tuning round so you get a clean baseline:
sudo -u postgres psql -c "SELECT pg_stat_statements_reset();"
Tune Autovacuum
Autovacuum reclaims dead row versions (the MVCC bloat every UPDATE and DELETE creates) and updates the visibility map, which query planner and index-only scans depend on. The default thresholds are tuned for small databases; large tables get vacuumed far too infrequently.
Understand the trigger math
Vacuum fires when dead tuples exceed autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples. With the default scale factor of 0.2, a 50-million-row table needs 10 million dead tuples before vacuum kicks in. That is too many.
Cluster-wide baseline settings
Add these to postgresql.conf. Adjust autovacuum_max_workers based on available cores.
cat >> /etc/postgresql/16/main/postgresql.conf << 'EOF'
autovacuum_max_workers = 4
autovacuum_naptime = 30s
autovacuum_vacuum_scale_factor = 0.02
autovacuum_analyze_scale_factor = 0.01
autovacuum_vacuum_cost_delay = 2ms
autovacuum_vacuum_cost_limit = 400
EOF
Reload (no restart needed for these parameters):
sudo systemctl reload postgresql
# or inside psql:
-- SELECT pg_reload_conf();
Per-table overrides for high-churn tables
Storage-level settings on individual tables override the cluster defaults and are preserved across pg_dump restores.
sudo -u postgres psql -d mydb << 'SQL'
ALTER TABLE events
SET (autovacuum_vacuum_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 1);
SQL
Monitor vacuum activity
sudo -u postgres psql -d mydb << 'SQL'
SELECT
relname,
n_dead_tup,
n_live_tup,
round(n_dead_tup::numeric / nullif(n_live_tup,0) * 100, 1) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 15;
SQL
Indexing Strategy
Indexes speed reads and slow writes. The goal is to have indexes that are actually used, sized correctly, and of the right type.
Find unused and missing indexes
sudo -u postgres psql -d mydb << 'SQL'
-- Unused indexes (no scans since last stats reset)
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;
SQL
sudo -u postgres psql -d mydb << 'SQL'
-- Tables doing sequential scans that might benefit from an index
SELECT relname, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_tup_read DESC
LIMIT 10;
SQL
Build indexes concurrently in production
CREATE INDEX CONCURRENTLY does not hold a table lock for the full build. It takes longer and cannot run inside a transaction block, but it is the only safe option on a live system.
sudo -u postgres psql -d mydb << 'SQL'
CREATE INDEX CONCURRENTLY idx_events_user_created
ON events (user_id, created_at DESC)
WHERE deleted_at IS NULL;
SQL
Choose the right index type
- B-tree (default): equality, range, ORDER BY. Right choice 90% of the time.
- BRIN: very large, naturally ordered tables (e.g., time-series with an append-only insert pattern). Tiny on disk, less precise.
- GIN:
JSONB, full-text search, array containment operators. - GiST / SP-GiST: geometric types, PostGIS, nearest-neighbour searches.
- Hash: equality-only lookups; rarely worth it over B-tree since PG 10 made them crash-safe.
sudo -u postgres psql -d mydb << 'SQL'
-- BRIN example for a large append-only log table
CREATE INDEX CONCURRENTLY idx_logs_ts_brin
ON logs USING BRIN (logged_at) WITH (pages_per_range = 64);
-- GIN for JSONB
CREATE INDEX CONCURRENTLY idx_events_payload
ON events USING GIN (payload jsonb_path_ops);
SQL
Keep indexes healthy
B-tree indexes accumulate bloat after heavy deletes. Check bloat with the pgstattuple extension and rebuild when avg_leaf_density drops below ~60%.
sudo -u postgres psql -d mydb << 'SQL'
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('idx_events_user_created');
SQL
Parallel Query Execution
PostgreSQL can split sequential scans, joins, and aggregations across multiple worker processes. This helps analytic workloads but adds overhead for OLTP queries that already use indexes.
Core parallel settings
cat >> /etc/postgresql/16/main/postgresql.conf << 'EOF'
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_worker_processes = 16
parallel_tuple_cost = 0.1
parallel_setup_cost = 500
min_parallel_table_scan_size = 8MB
min_parallel_index_scan_size = 512kB
EOF
Reload for the cost parameters; max_worker_processes requires a restart.
sudo systemctl restart postgresql
Verify the planner uses parallel plans
sudo -u postgres psql -d mydb << 'SQL'
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT user_id, count(*) FROM events GROUP BY user_id;
SQL
Look for Gather or Gather Merge nodes in the plan output. If they are absent on a large table scan, the table may be below min_parallel_table_scan_size, or parallelism was disabled by a function in the query.
Force or disable parallel for testing
sudo -u postgres psql -d mydb << 'SQL'
-- Force parallel (session only, for testing)
SET max_parallel_workers_per_gather = 4;
SET force_parallel_mode = regress; -- PG 15 and earlier; removed in PG 16
-- Disable parallel on a specific slow query
SET max_parallel_workers_per_gather = 0;
SQL
Shared Buffers and Memory Settings
These are not the focus of this guide, but parallel and autovacuum tuning interact with memory. A quick summary of the most impactful values:
- shared_buffers: set to 25% of total RAM as a starting point.
- effective_cache_size: set to 50–75% of RAM; this is a planner hint, not an allocation.
- work_mem: memory per sort/hash operation per parallel worker. Start at 32–64 MB and raise only for sessions doing heavy analytics. Multiply by max connections × parallel workers before raising cluster-wide.
- maintenance_work_mem: used by VACUUM, CREATE INDEX, and autovacuum workers. 256 MB–1 GB is reasonable on servers with 16+ GB RAM.
Verification
sudo -u postgres psql << 'SQL'
SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
'shared_preload_libraries',
'autovacuum_vacuum_scale_factor',
'max_parallel_workers_per_gather',
'work_mem',
'shared_buffers'
);
SQL
The source column confirms whether a value came from postgresql.conf, an ALTER SYSTEM call, or the compiled default. Any value still showing default after your edits means the config file change did not take effect — double-check the file path and that a reload or restart was performed.
Troubleshooting
- pg_stat_statements shows no rows: the extension was not created in the target database, or
shared_preload_librarieswas not set before the last restart. CheckSHOW shared_preload_libraries;inside psql. - Autovacuum still not running: check
pg_stat_activityfor autovacuum workers. Ifautovacuum = offin pg_settings, it was explicitly disabled. Also check per-table storage options withSELECT reloptions FROM pg_class WHERE relname = 'mytable';. - CONCURRENTLY index build fails: the index is left in an invalid state. Drop it with
DROP INDEX CONCURRENTLY idx_name;and rebuild. Checkpg_indexesfor rows with the index marked invalid. - Parallel plans absent after tuning: some functions are marked
PARALLEL UNSAFEby default. Check withSELECT proname, proparallel FROM pg_proc WHERE proname = 'yourfunction';. You can change this toSAFEonly if the function genuinely has no side effects. - OOM killer targeting PostgreSQL after raising work_mem:
work_memis allocated per sort node per connection. Under load it multiplies fast. Lower it and useSET work_memat the session level for heavy queries instead.
Frequently asked questions
- Does lowering autovacuum_vacuum_scale_factor hurt write performance?
- It increases how often autovacuum runs, which adds background I/O. Tuning autovacuum_vacuum_cost_delay and autovacuum_vacuum_cost_limit throttles that I/O so OLTP workloads are not noticeably affected. The default cost delay of 20ms is usually too conservative; 2ms with a higher cost limit gives faster vacuums with similar per-operation overhead.
- When should I use a partial index instead of a full-table index?
- Use a partial index (with a WHERE clause) when a large fraction of rows are never queried — for example, indexing only active or non-deleted rows. The index is smaller, cheaper to maintain, and the planner will prefer it for matching queries.
- Why does pg_stat_statements show the same query with different parameter values as one row?
- pg_stat_statements normalizes queries by replacing literals with placeholders, so SELECT * FROM users WHERE id = 1 and id = 2 collapse into a single entry. This is intentional; it lets you see aggregate cost across all executions of the same query shape.
- Can I enable parallel query for a specific table only?
- Yes. Set the table's parallel_workers storage option with ALTER TABLE t SET (parallel_workers = 4). The planner treats this as a strong hint for the worker count for that table regardless of the global min_parallel_table_scan_size threshold.
- How do I tell if table bloat is actually hurting performance versus just wasting disk?
- Bloat hurts performance when it forces sequential scans to read many dead-tuple pages or when index scans traverse bloated index levels. Use EXPLAIN (ANALYZE, BUFFERS) and watch Buffers: shared hit/read counts. High read counts on a heavily-updated table after a restart usually point to bloat. The pgstattuple extension gives exact bloat percentages per relation.
Related guides
AI and Artificial-Life Tools on Linux
Set up open-source AI/ML and artificial-life toolkits on Linux: PyTorch, JAX, DEAP, Avida, NetLogo, and RL environments with GPU driver guidance.
Assembly Language on Linux: A Starter Guide
Write x86-64 assembly on Linux from scratch: install NASM and GAS, learn syscalls, assemble and link a working program, then inspect and debug it.
How to Benchmark Disk Performance with fio
Learn to benchmark Linux disk performance with fio: writing job files, testing latency and throughput, and interpreting IOPS and percentile output correctly.
The Linux Boot Process Explained
Trace the full Linux boot sequence from UEFI firmware through GRUB2, the kernel, initramfs, and systemd to your login prompt — with diagnostics at each stage.