PostgreSQL Performance Tuning
PostgreSQL Performance Tuning: A Practical Guide for Production Workloads
PostgreSQL is one of the most capable relational databases available, but its default configuration is intentionally conservative — prioritizing stability and correctness over raw throughput. In production environments, even modest workloads can expose performance bottlenecks if the database isn't tuned appropriately. This article walks through the essential tuning dimensions, from configuration parameters to query-level optimizations, with practical examples you can apply immediately.
Understanding the Query Lifecycle
Before adjusting any settings, it helps to understand what happens when a query executes. PostgreSQL receives a SQL statement, parses it into an abstract syntax tree, rewrites it (applying rules like view expansions), and then the planner evaluates multiple execution strategies using cost estimates based on table statistics. The chosen plan is executed by the executor, which reads and writes pages from the buffer cache or disk. Every stage introduces potential inefficiency — and each is addressable through tuning.
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT o.order_id, o.total, COUNT(l.line_id) AS line_count
FROM orders o
JOIN order_lines l ON l.order_id = o.order_id
WHERE o.created_at > '2025-01-01'
GROUP BY o.order_id, o.total
HAVING COUNT(l.line_id) > 5
ORDER BY line_count DESC
LIMIT 20;
Running EXPLAIN (ANALYZE, BUFFERS) as part of your diagnostics routine gives you the actual execution time, buffer hit ratios, and the plan shape — information no benchmark can replicate.
Core Configuration Parameters
PostgreSQL's postgresql.conf contains hundreds of parameters. The following are the highest-impact settings for most production deployments.
Memory Settings
Memory is the single most influential resource category. Misconfigured memory leads to excessive disk I/O, which is orders of magnitude slower than in-memory operations.
| Parameter | Typical Value | Description |
|---|---|---|
shared_buffers | 25% of RAM | PostgreSQL's main buffer cache for table and index pages |
effective_cache_size | 75% of RAM | Planner heuristic for how much memory is available for OS caching |
work_mem | 4–16 MB | Per-operation memory for sorts and hashes |
maintenance_work_mem | 256–1024 MB | Memory for VACUUM, CREATE INDEX, and ALTER TABLE |
# postgresql.conf — production-tier example
shared_buffers = 8GB
effective_cache_size = 24GB
work_mem = 8MB
maintenance_work_mem = 512MB
Rule of thumb: Do not setwork_memblindly high. It allocates per operation per connection. With 100 concurrent connections running complex sorts,100 × 8 MB × 3 operations = 2.4 GBadditional memory usage. If your workload is write-heavy or has many concurrent join queries, lean toward the lower end and compensate with better indexing.
The OS itself acts as a second buffer cache. Setting effective_cache_size to roughly 75% of total RAM signals the planner that sequential scans and index scans are cheaper than they would be on a system with no OS page cache. This is purely a planner hint — PostgreSQL does not enforce it — but getting it wrong causes the planner to avoid sequential scans that the OS could serve entirely from cache.
WAL and Checkpoint Tuning
Write-Ahead Logging (WAL) safeguards durability but becomes a bottleneck when checkpoints are frequent or undersized.
# postgresql.conf — write-heavy workload
max_wal_size = 4GB
min_wal_size = 1GB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
With the defaults (max_wal_size = 1GB, checkpoint_timeout = 5min), a busy write workload triggers checkpoints every few minutes, forcing the entire buffer cache to flush to disk at once. Increasing max_wal_size and checkpoint_completion_target spreads this I/O load. Setting checkpoint_completion_target = 0.9 tells PostgreSQL to finish writing checkpoint data over 90% of the interval rather than rushing to complete it quickly — a meaningful reduction in I/O spikes.
Connection and Process Management
max_connections = 200
superuser_reserved_connections = 3
Beyond raw connection count, consider connection pooling. Tools like PgBouncer operate at the transaction level, multiplexing many application connections over a smaller pool of server connections. This lets you serve thousands of short-lived connections without overwhelming PostgreSQL's process overhead.
Indexing Strategy
Indexes are the most common performance lever. An appropriate index can turn a full-table scan into an index scan that reads a fraction of the data.
B-Tree Indexes and Coverage
B-tree indexes handle equality, range, and ordering queries efficiently. The key principle is index selectivity: place the most selective columns on the left.
-- Poor choice: low-selectivity column first
CREATE INDEX idx_orders_bad ON orders (status, created_at);
-- Better: high-selectivity column first
CREATE INDEX idx_orders_good ON orders (created_at, status);
Partial Indexes
Partial indexes let you index a subset of rows, reducing index size and improving scan performance.
-- Only index active orders
CREATE INDEX idx_orders_active ON orders (customer_id)
WHERE status = 'active';
A partial index on 5% of rows is dramatically smaller and faster than a full-table index, and the planner will prefer it when the WHERE clause matches.
Covering Indexes (INCLUDE)
A covering index stores all columns needed for a query directly in the index, eliminating the need to visit the heap.
CREATE INDEX idx_orders_covering
ON orders (customer_id, created_at)
INCLUDE (total, status);
When the planner uses this index for SELECT total, status FROM orders WHERE customer_id = 42 AND created_at > '2025-01-01', it reads everything from the index leaf pages alone — a pure index-only scan with zero heap access.
Monitoring Unused and Duplicate Indexes
-- Find indexes with low read activity relative to writes
SELECT
schemaname, tablename, indexname,
idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
-- Detect duplicate indexes (same leading column set)
SELECT
a.indexname AS idx_a,
b.indexname AS idx_b,
a.indexdef AS def_a,
b.indexdef AS def_b
FROM pg_indexes a
JOIN pg_indexes b ON a.tablename = b.tablename
AND a.indexname < b.indexname
AND a.indexdef LIKE b.indexdef || '%';
Removing unused and redundant indexes reduces write overhead — every INSERT, UPDATE, and DELETE must maintain each index.
Query-Level Optimizations
Even well-tuned configuration cannot compensate for inefficient queries.
Avoid N+1 Query Patterns
# Anti-pattern: one query per row
for order in orders:
lines = db.execute("SELECT * FROM order_lines WHERE order_id = ?", order.id)
# Fix: single batched query
lines = db.execute(
"SELECT * FROM order_lines WHERE order_id = ANY($1)",
[o.id for o in orders]
)
Frameworks like Django ORM, SQLAlchemy, and Prisma offer select_related / prefetch_related mechanisms. Use them deliberately.
Force Index Usage When Needed
The planner sometimes chooses a sequential scan over an index when it estimates the table is small or the query is non-selective. You can guide it without rewriting the query:
SET enable_seqscan = off; -- session-level, use cautiously
SET enable_bitmapscan = off;
-- Or use a query-level hint via CTE
WITH forced_index AS (
SELECT /*+ IndexScan(orders idx_orders_good) */ *
FROM orders
WHERE created_at > '2025-01-01'
)
SELECT * FROM forced_index;
Note: PostgreSQL has no nativeUSE INDEXsyntax like MySQL. TheSET enable_*approach and wrapper queries are the closest equivalents. Use them sparingly — they bypass the planner's cost model and can degrade performance if conditions change.
Keep Queries Simple and Sargable
A sargable query allows the planner to use an index. Functions applied to indexed columns on the query side typically prevent index usage:
-- Not sargable: function wraps the column
SELECT * FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2025;
-- Sargable: range condition on the raw column
SELECT * FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';
The second form lets PostgreSQL use a B-tree range scan. The first forces a sequential scan with a filter, reading every row.
Statistics and the Planner
PostgreSQL's planner relies on column statistics to estimate row counts and choose plans. Stale statistics lead to bad plan choices.
-- Update statistics for a specific table
ANALYZE orders;
-- Update for the entire database
ANALYZE;
-- Check when statistics were last updated
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY last_analyze DESC;
For large tables with skewed data distributions, use extended statistics to capture correlations between columns:
CREATE STATISTICS sty_orders (dependencies) ON status, created_at FROM orders;
ANALYZE orders;
This helps the planner recognize that, for example, status = 'shipped' correlates with older created_at values, leading to better cardinality estimates and more accurate cost calculations.
Vacuum and Autovacuum Tuning
PostgreSQL uses MVCC — each UPDATE and DELETE leaves dead tuples that must be cleaned up by VACUUM. Without regular vacuuming, tables and indexes bloat, scans slow down, and transaction ID wraparound becomes a catastrophic risk.
# postgresql.conf — autovacuum tuning
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 30s
autovacuum_vacuum_threshold = 50
autovacuum_analyze_threshold = 50
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02
The default scale factors (10% for vacuum, 20% for analyze) are designed for general-purpose workloads. For high-churn tables, lowering these values ensures vacuum runs more frequently relative to table size.
-- Per-table overrides for especially active tables
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02);
ALTER TABLE orders SET (autovacuum_vacuum_threshold = 25);
Monitor bloat with:
SELECT
schemaname, relname,
n_dead_tup, n_live_tup,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
If n_dead_tup consistently exceeds 10% of n_live_tup between autovacuum runs, the thresholds need adjustment.
Monitoring and Observability
Tuning is iterative. Without visibility, you're guessing.
Built-in Views
-- Top queries by total time
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- Waiting queries (lock contention)
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle';
pg_stat_statements is an extension, not enabled by default. Add it early:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
Add it to shared_preload_libraries in postgresql.conf to ensure it's available from startup.
Key Metrics to Track
- Buffer hit ratio: Should be above 99% for read-heavy workloads. Calculated as
sum(blks_hit) / (sum(blks_hit) + sum(blks_read))frompg_stat_database. - Cache hit ratio per database: Identifies which databases are under-resourced.
- Long-running queries: Spot queries exceeding expected duration via
pg_stat_activity. - Lock waits: High
Lockwait events indicate contention that may require query restructuring. - Replication lag: If using streaming replication, monitor
pg_stat_replicationfor application lag.
Partitioning for Large Tables
When a single table grows beyond billions of rows, scanning it becomes expensive regardless of indexing. declarative partitioning lets PostgreSQL divide a table into manageable child segments.
CREATE TABLE orders (
order_id BIGINT,
customer_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
total NUMERIC(12,2),
status TEXT
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_y2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE orders_y2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE orders_y2026 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
Queries with a created_at range predicate prune irrelevant partitions automatically. A query against 2025 data touches only one partition instead of scanning decades of history. This is particularly effective for time-series workloads, log ingestion, and audit tables.
Conclusion
PostgreSQL performance tuning is not a one-time configuration task — it is a continuous practice that spans configuration, indexing, query design, statistics management, and monitoring. The highest-leverage actions are:
- Set memory parameters correctly —
shared_buffers,effective_cache_size, andwork_memare the foundation. - Build the right indexes — targeted, partial, and covering indexes eliminate the most common scan bottlenecks.
- Write sargable queries — avoid wrapping indexed columns in functions and prefer batched operations over N+1 patterns.
- Keep autovacuum on schedule — dead tuple bloat is invisible until it isn't.
- Monitor relentlessly —
pg_stat_statements,pg_stat_activity, and table-level stats give you the feedback loop needed to iterate.
Start with EXPLAIN (ANALYZE, BUFFERS) on your slowest queries, identify the dominant bottleneck — whether it's a missing index, a bad plan, memory pressure, or vacuum lag — and address it before moving to the next. Systematic, evidence-driven tuning outperforms parameter guessing every time.