PostgreSQL Performance Tuning: Indexing, Query Optimization & Configuration
Speed up your PostgreSQL database with practical indexing strategies, EXPLAIN ANALYZE, query optimization patterns, connection pooling, and server configuration tuning.
Why Performance Matters
A single slow query can bring down your entire application. PostgreSQL is fast out of the box, but as your data grows from thousands to millions of rows, you need to understand indexing, query planning, and configuration tuning.
This guide covers practical techniques — no theory without application.
Step 1: Find Slow Queries
Enable Slow Query Logging
-- postgresql.conf or ALTER SYSTEM
ALTER SYSTEM SET log_min_duration_statement = 500; -- Log queries > 500ms
ALTER SYSTEM SET log_statement = 'none'; -- Don't log all queries
SELECT pg_reload_conf();pg_stat_statements (Best Tool)
-- Enable the extension
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Find top 10 slowest queries by total time
SELECT
round(total_exec_time::numeric, 2) AS total_ms,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct,
left(query, 100) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;This shows you where time is actually spent — often a few queries account for 80% of database time.
Step 2: Understand EXPLAIN ANALYZE
EXPLAIN ANALYZE is your most important debugging tool. It shows exactly how PostgreSQL executes a query.
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE user_id = 12345
AND status = 'completed'
ORDER BY created_at DESC
LIMIT 10;Output:
Limit (cost=1234.56..1234.78 rows=10 width=120) (actual time=45.2..45.3 rows=10 loops=1)
-> Sort (cost=1234.56..1267.89 rows=1500 width=120) (actual time=45.2..45.2 rows=10 loops=1)
Sort Key: created_at DESC
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on orders (cost=0.00..1200.00 rows=1500 width=120) (actual time=0.02..42.1 rows=1500 loops=1)
Filter: ((user_id = 12345) AND (status = 'completed'))
Rows Removed by Filter: 498500
Planning Time: 0.15 ms
Execution Time: 45.4 ms
Red Flags to Look For
| Red Flag | Meaning | Fix |
|----------|---------|-----|
| Seq Scan on large table | Full table scan | Add an index |
| Rows Removed by Filter: 498500 | Scanning way too many rows | Index on filter columns |
| Sort Method: external merge Disk | Sorting on disk | Increase work_mem or add index |
| Hash Join with huge table | Expensive join | Index on join column |
| Nested Loop with large outer | O(n*m) join | Check statistics, consider hash join |
| actual rows far from rows estimate | Bad statistics | Run ANALYZE tablename |
Step 3: Indexing Strategies
B-Tree Index (Default)
Best for: equality (=), range (<, >, BETWEEN), sorting, LIKE 'prefix%'
-- Single column
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- Composite index (column order matters!)
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
-- Partial index (only index what you query)
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';
-- This index is tiny and lightning fast for: WHERE status = 'pending'
-- Index for sorting
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
-- Covers: WHERE user_id = X ORDER BY created_at DESCComposite Index Column Order
The leftmost column is used first. Think of it like a phone book (last name, then first name).
-- Index: (user_id, status, created_at)
-- Uses index:
WHERE user_id = 1 -- Yes
WHERE user_id = 1 AND status = 'active' -- Yes
WHERE user_id = 1 AND status = 'active' AND created_at > '2026-01-01' -- Yes
-- Does NOT use index efficiently:
WHERE status = 'active' -- No (skips first column)
WHERE created_at > '2026-01-01' -- No (skips first two columns)GIN Index (Full-Text Search, JSONB, Arrays)
-- JSONB queries
CREATE INDEX idx_metadata ON products USING GIN (metadata);
-- Now fast: WHERE metadata @> '{"color": "red"}'
-- Full-text search
CREATE INDEX idx_search ON articles USING GIN (to_tsvector('english', title || ' ' || body));
-- Now fast: WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('postgresql & tuning')
-- Array contains
CREATE INDEX idx_tags ON posts USING GIN (tags);
-- Now fast: WHERE tags @> ARRAY['javascript']GiST Index (Geometric, Range, Nearest Neighbor)
-- Geographic queries (with PostGIS)
CREATE INDEX idx_location ON stores USING GIST (location);
-- Range types
CREATE INDEX idx_booking_dates ON bookings USING GIST (date_range);
-- Fast: WHERE date_range && '[2026-01-01, 2026-01-31]'BRIN Index (Huge Tables with Natural Order)
-- For tables where data is naturally ordered (e.g., time-series)
CREATE INDEX idx_logs_created ON logs USING BRIN (created_at);
-- Tiny index size, great for append-only tables with billions of rowsStep 4: Query Optimization Patterns
Use Covering Indexes (Index-Only Scans)
-- If you only need user_id and email:
CREATE INDEX idx_users_covering ON users (status) INCLUDE (user_id, email);
-- This query now reads ONLY the index, never touches the table:
SELECT user_id, email FROM users WHERE status = 'active';Avoid SELECT *
-- Bad: reads all columns from disk
SELECT * FROM orders WHERE user_id = 123;
-- Good: only reads what's needed (can use index-only scan)
SELECT id, total, status FROM orders WHERE user_id = 123;Optimize COUNT
-- Slow on large tables (exact count)
SELECT COUNT(*) FROM orders; -- Scans entire table
-- Fast approximate count
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';
-- Fast exact count with conditions (if indexed)
SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- Make sure there's an index on statusBatch Operations
-- Bad: inserting one by one in a loop
INSERT INTO logs (message) VALUES ('event 1');
INSERT INTO logs (message) VALUES ('event 2');
-- 1000 round trips for 1000 rows
-- Good: batch insert
INSERT INTO logs (message) VALUES
('event 1'), ('event 2'), ('event 3'), ...;
-- 1 round trip
-- Good: COPY for bulk loading
COPY logs (message) FROM '/path/to/data.csv' CSV;
-- Fastest method for bulk dataUse EXISTS Instead of IN for Subqueries
-- Slow with large subquery result
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE country = 'IN');
-- Fast (stops at first match)
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM users u
WHERE u.id = o.user_id AND u.country = 'IN'
);Pagination: Use Keyset, Not OFFSET
-- Bad: OFFSET scans and discards rows (gets slower with higher pages)
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 10000;
-- Scans 10020 rows, returns 20
-- Good: keyset pagination (constant speed)
SELECT * FROM products
WHERE id > 10000 -- Last seen ID
ORDER BY id
LIMIT 20;
-- Scans only 20 rowsStep 5: Server Configuration
Memory Settings
# postgresql.conf
# Total RAM dedicated to PostgreSQL shared memory
# Rule: 25% of total system RAM
shared_buffers = 4GB # For a 16GB server
# Memory per operation (sort, hash join, etc.)
# Rule: 64MB-256MB depending on query complexity
work_mem = 128MB
# Memory for maintenance (VACUUM, CREATE INDEX, etc.)
maintenance_work_mem = 1GB
# OS cache hint
# Rule: 50-75% of total RAM
effective_cache_size = 12GBWAL and Checkpoint Settings
# Larger WAL for write-heavy workloads
wal_buffers = 64MB
max_wal_size = 4GB
min_wal_size = 1GB
# Spread checkpoints to avoid I/O spikes
checkpoint_completion_target = 0.9Connection Settings
max_connections = 200
# But actually, use connection pooling!
# PgBouncer or Supavisor in front of PostgreSQLStep 6: Connection Pooling
PostgreSQL creates a new process per connection (~10MB RAM each). 200 connections = 2GB just for connections.
PgBouncer
# pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp
[pgbouncer]
listen_port = 6432
pool_mode = transaction # Best for web apps
max_client_conn = 1000 # Clients connect to PgBouncer
default_pool_size = 20 # PgBouncer opens only 20 connections to PostgreSQLApp connects to PgBouncer (port 6432) instead of PostgreSQL (port 5432). 1000 app connections share 20 database connections.
Step 7: Maintenance
VACUUM and ANALYZE
-- Update statistics (helps query planner make better decisions)
ANALYZE orders;
-- Reclaim dead tuple space
VACUUM orders;
-- Full maintenance (rewrites table, locks it)
VACUUM FULL orders; -- Use sparingly, causes downtime
-- Check dead tuples
SELECT relname, n_dead_tup, n_live_tup,
round(n_dead_tup::numeric / greatest(n_live_tup, 1) * 100, 2) AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;Monitor Index Usage
-- Find unused indexes (wasting disk and slowing writes)
SELECT
schemaname, tablename, indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
-- Drop unused indexes
DROP INDEX idx_never_used;Find Missing Indexes
-- Tables with too many sequential scans (need indexes)
SELECT
relname,
seq_scan,
idx_scan,
round(100.0 * seq_scan / greatest(seq_scan + idx_scan, 1), 2) AS seq_scan_pct,
pg_size_pretty(pg_relation_size(relid)) AS table_size
FROM pg_stat_user_tables
WHERE seq_scan > 100
ORDER BY seq_scan DESC
LIMIT 10;Cheat Sheet
-- Find slow queries
SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
-- Analyze a query
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;
-- Check index usage
SELECT indexname, idx_scan FROM pg_stat_user_indexes WHERE tablename = 'orders';
-- Table sizes
SELECT tablename, pg_size_pretty(pg_total_relation_size(tablename::text))
FROM pg_tables WHERE schemaname = 'public' ORDER BY pg_total_relation_size(tablename::text) DESC;
-- Kill long-running query
SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE duration > interval '5 minutes';
-- Current connections
SELECT count(*), state FROM pg_stat_activity GROUP BY state;The goal is not to index everything — it's to understand your query patterns and add targeted indexes that eliminate sequential scans on large tables. Start with pg_stat_statements, fix the top 5 slowest queries, and you'll solve 80% of your performance problems.