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
pg_stat_statements (Best Tool)
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.
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%'
Composite Index Column Order
The leftmost column is used first. Think of it like a phone book (last name, then first name).
GIN Index (Full-Text Search, JSONB, Arrays)
GiST Index (Geometric, Range, Nearest Neighbor)
BRIN Index (Huge Tables with Natural Order)
Step 4: Query Optimization Patterns
Use Covering Indexes (Index-Only Scans)
Avoid SELECT *
Optimize COUNT
Batch Operations
Use EXISTS Instead of IN for Subqueries
Pagination: Use Keyset, Not OFFSET
Step 5: Server Configuration
Memory Settings
WAL and Checkpoint Settings
Connection Settings
Step 6: Connection Pooling
PostgreSQL creates a new process per connection (~10MB RAM each). 200 connections = 2GB just for connections.
PgBouncer
App connects to PgBouncer (port 6432) instead of PostgreSQL (port 5432). 1000 app connections share 20 database connections.
Step 7: Maintenance
VACUUM and ANALYZE
Monitor Index Usage
Find Missing Indexes
Cheat Sheet
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.