
PostgreSQL Performance Optimization - Index Strategies & Query Tuning
PostgreSQL ships with conservative, general-purpose defaults. Achieving high performance in production requires tuning memory settings, index strategies, query optimization, and maintenance tasks to match your workload. This guide walks through concrete steps from detecting slow queries with EXPLAIN
Can Kaya
Security Specialist
PostgreSQL ships with conservative, general-purpose defaults. Achieving high performance in production requires tuning memory settings, index strategies, query optimization, and maintenance tasks to match your workload. This guide walks through concrete steps from detecting slow queries with EXPLAIN ANALYZE to choosing the right index type, tuning postgresql.conf, and configuring autovacuum.
Memory and Connection Settings (postgresql.conf)
Memory allocation has the biggest impact on PostgreSQL performance. Adjust the following values based on your server's RAM:
# Shared memory - 25% of total RAM
shared_buffers = 4GB
# Memory per query operation
work_mem = 64MB
# Memory for maintenance operations (VACUUM, CREATE INDEX)
maintenance_work_mem = 1GB
# OS cache estimate - 75% of total RAM
effective_cache_size = 12GB
# WAL settings
wal_buffers = 64MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
# Parallel queries
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
# Random disk read cost (lower for SSD)
random_page_cost = 1.1 # HDD: 4.0, SSD: 1.1
effective_io_concurrency = 200 # for SSD
⚠️ Warning: work_mem is allocated per query operation (sort, hash join). With 100 concurrent connections and 3 sort operations per query, total consumption can reach 100 x 3 x 64MB = 19.2 GB. Adjust this value carefully based on your connection count.
| Parameter | 8 GB RAM | 16 GB RAM | 32 GB RAM | 64 GB RAM |
|---|---|---|---|---|
| shared_buffers | 2 GB | 4 GB | 8 GB | 16 GB |
| work_mem | 32 MB | 64 MB | 128 MB | 256 MB |
| maintenance_work_mem | 512 MB | 1 GB | 2 GB | 4 GB |
| effective_cache_size | 6 GB | 12 GB | 24 GB | 48 GB |
Detecting Slow Queries with EXPLAIN ANALYZE
Most performance issues stem from slow queries. PostgreSQL's EXPLAIN ANALYZE command shows the actual execution plan, duration of each step, and the difference between estimated and actual row counts. You need to correctly identify the bottleneck before starting optimization.
-- Basic usage
EXPLAIN ANALYZE SELECT * FROM orders
WHERE customer_id = 42 AND status = 'active'
ORDER BY created_at DESC LIMIT 20;
-- Add BUFFERS and TIMING for detailed output
EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2025-01-01'
ORDER BY o.total DESC;
Key things to look for in EXPLAIN output:
-
Seq Scan vs Index Scan Seq Scan reads the entire table. If you see Seq Scan on large tables with WHERE clauses, you're missing an index. On small tables (<10,000 rows), Seq Scan can be normal.
-
Rows (estimated vs actual) If there's a large gap (10x+) between estimated and actual row counts, statistics are outdated. Run
ANALYZE tablename;to refresh them. -
Sort Method: external merge The sort operation spilled to disk because it didn't fit in memory. Increase
work_memor create an index for the sort column. -
Buffers: shared hit vs read shared hit = pages read from cache, read = pages read from disk. A low hit ratio may indicate insufficient shared_buffers or a dataset larger than available memory.
To automatically log slow queries, add these settings to postgresql.conf:
# Log queries taking longer than 500ms
log_min_duration_statement = 500
# Enable pg_stat_statements extension
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
Use the pg_stat_statements extension to find the most resource-intensive queries:
SELECT query,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS avg_ms,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
Index Strategies: B-tree, GIN, GiST, and BRIN
Choosing the right index type can improve query performance by 100x. PostgreSQL offers four main index types, each optimized for different data structures and query patterns.
| Index Type | Use Case | Size | When to Choose |
|---|---|---|---|
| B-tree (default) | Equality and range queries | Medium | WHERE id = X, WHERE date > Y, ORDER BY |
| GIN | Array, JSONB, full-text search | Large | JSONB @> operator, tsvector, array contains |
| GiST | Geometric, range, full-text | Medium | PostGIS, range overlap, nearest-neighbor |
| BRIN | Naturally ordered large tables | Very small | Timestamp columns, log tables, IoT data |
B-tree Index Examples
-- Composite index: frequently used WHERE + ORDER BY combination
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, created_at DESC);
-- Partial index: only index active records (smaller, faster)
CREATE INDEX idx_orders_active
ON orders (customer_id, created_at)
WHERE status = 'active';
-- Covering index: add required columns with INCLUDE for index-only scan
CREATE INDEX idx_orders_covering
ON orders (customer_id)
INCLUDE (total, status);
GIN Index: JSONB and Full-Text Search
-- GIN index for JSONB field
CREATE INDEX idx_products_metadata
ON products USING GIN (metadata jsonb_path_ops);
-- Query: search within JSONB
SELECT * FROM products
WHERE metadata @> '{"color": "red", "size": "L"}'::jsonb;
-- GIN index for full-text search
ALTER TABLE articles ADD COLUMN search_vector tsvector;
CREATE INDEX idx_articles_search
ON articles USING GIN (search_vector);
-- Auto-update with trigger
CREATE TRIGGER articles_search_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_vector, 'pg_catalog.english', title, content);
BRIN Index: Time Series and Log Tables
BRIN (Block Range Index) provides extremely compact and effective indexing for physically ordered data. On billion-row log tables, a B-tree index can consume gigabytes while BRIN achieves the same result in just a few megabytes.
-- BRIN index for log table (created_at naturally increases)
CREATE INDEX idx_logs_created
ON access_logs USING BRIN (created_at)
WITH (pages_per_range = 32);
-- Size comparison (100M row table)
-- B-tree: ~2.1 GB
-- BRIN: ~48 KB
💡 Index Maintenance: Unused indexes degrade write performance and consume disk space. Use pg_stat_user_indexes to find and remove unused indexes: SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;
Autovacuum Configuration
PostgreSQL's MVCC (Multi-Version Concurrency Control) architecture doesn't immediately remove old row versions (dead tuples) during UPDATE and DELETE operations. Autovacuum cleans up these dead tuples, updates statistics, and prevents transaction ID wraparound. Default settings work for small databases but fall short under high write volumes.
# Increase autovacuum worker count
autovacuum_max_workers = 5 # default: 3
# Run more frequently
autovacuum_naptime = 30s # default: 1min
# Accumulate fewer dead tuples before triggering
autovacuum_vacuum_scale_factor = 0.05 # default: 0.2
autovacuum_analyze_scale_factor = 0.025 # default: 0.1
# I/O throttling - can be more aggressive on SSD
autovacuum_vacuum_cost_limit = 1000 # default: 200
autovacuum_vacuum_cost_delay = 5ms # default: 2ms (PG 15+)
For write-heavy tables, you can define per-table autovacuum settings:
-- Aggressive vacuum for write-heavy table
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_limit = 2000
);
-- Check dead tuple status
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / nullif(n_live_tup, 0), 4) AS dead_ratio,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
⚠️ Transaction ID Wraparound: PostgreSQL uses 32-bit transaction IDs (approximately 2 billion). Autovacuum recycles these IDs. If autovacuum is blocked or can't keep up, the database will refuse new write operations. Monitor with: SELECT datname, age(datfrozenxid) FROM pg_database; - the age value should not exceed 1 billion.
Query Optimization Techniques
Beyond indexes and memory settings, how you write queries directly impacts performance. These techniques address common performance issues:
Specify Required Columns Instead of SELECT *
SELECT * reads all columns, transfers them over the network, and prevents index-only scans. Only select the columns you need:
-- BAD: reads all columns, index-only scan not possible
SELECT * FROM orders WHERE customer_id = 42;
-- GOOD: only required columns, index-only scan with covering index
SELECT id, total, status FROM orders WHERE customer_id = 42;
-- Pagination: OFFSET slows down with large values
-- BAD: skips 1M rows to read 20
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 1000000;
-- GOOD: keyset pagination (cursor-based)
SELECT * FROM orders
WHERE id > 1000000
ORDER BY id LIMIT 20;
For database security, check our Database Security guide. For connection management, see our PgBouncer Connection Pooling guide. For backup strategies, read our Database Backup Automation guide. The PostgreSQL Performance Tips and PostgreSQL Wiki - Tuning are useful additional resources.
Frequently Asked Questions
Should I set shared_buffers higher than 25% of RAM?
Generally no. PostgreSQL also relies on the OS page cache. Pushing shared_buffers above 40% can cause double buffering issues and actually degrade performance. For dedicated database servers, 25-30% is the optimal range.
Which queries should I add indexes to?
Use pg_stat_statements to identify the most called and slowest queries. Add indexes to columns used in WHERE, JOIN, and ORDER BY clauses. But don't index every column - each index degrades write performance and consumes disk space. Verify with EXPLAIN ANALYZE that the index is actually being used.
Can autovacuum be disabled in production?
Absolutely not. If autovacuum is disabled, dead tuples accumulate, tables bloat, statistics become stale, and in the worst case, the database refuses write operations due to transaction ID wraparound. If you find it slow, optimize its settings - don't disable it.
Do I need to restart PostgreSQL after changing settings?
Some settings (work_mem, effective_cache_size) can be applied instantly with SET or pg_reload_conf(). However, shared_buffers, max_connections, and shared_preload_libraries require a restart. The context column in the pg_settings view shows what each setting requires.
Should I use connection pooling?
Yes, especially if you have 50+ concurrent connections. PostgreSQL creates a separate process for each connection, consuming approximately 5-10 MB of memory each. A connection pooler like PgBouncer can reduce hundreds of application connections to just a few dozen database connections.
Conclusion
PostgreSQL performance optimization is not a one-time task but an ongoing process of monitoring and tuning. Adjust memory allocation with shared_buffers and work_mem for your server, identify bottlenecks with EXPLAIN ANALYZE, choose the right index type, and ensure autovacuum is running healthily. Regularly monitor the slowest queries with pg_stat_statements and improve query performance with techniques like keyset pagination and covering indexes.
High-Performance Servers for Your Database
Maximize your database performance with Hosted Cloud's NVMe SSD cloud servers. Reduce query times with low latency and high IOPS.
Explore Cloud Server Plans →Can Kaya
Security Specialist
CISSP-certified security expert creating content on cybersecurity, DDoS protection, and server hardening.
Comments coming soon