PostgreSQL Query Optimization: Applying EXPLAIN ANALYZE Knowledge
· 10 min read
PostgreSQL performance optimization involves recognizing patterns that indicate bottlenecks and applying targeted solutions. This guide covers practical optimization patterns from query-level improvements to database-wide configurations.
Performance Red Flags
Understanding query plans is only half the battle. The real value comes from recognizing patterns that indicate performance problems. These red flags appear repeatedly in slow queries.
1. High Filter Ratios
When PostgreSQL reads many rows only to discard most of them, you have a filter ratio problem:
1
2
3
4
Seq Scan on orders (cost=0.00..26432.00 rows=539294 width=116)
(actual time=0.006..56.066 rows=538622 loops=1)
Filter: ((created_at >= '2024-01-01 00:00:00') AND ((status)::text = 'completed'::text))
Rows Removed by Filter: 461378
Understanding the Problem
This query:
- Read 1,000,000 rows (538,622 + 461,378)
- Kept only 538,622 rows (54%)
- Discarded 461,378 rows (46%)
That’s like reading an entire phone book to find people whose last names start with A-M!
Solutions
- Add a compound index:
1
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
- Or separate indexes (if you query by each column independently):
1 2
CREATE INDEX idx_orders_status ON orders(status); CREATE INDEX idx_orders_created_at ON orders(created_at);
- Consider partial indexes for common filters:
1 2
CREATE INDEX idx_completed_orders_2024 ON orders(created_at) WHERE status = 'completed' AND created_at >= '2024-01-01';
2. Lossy Bitmap Scans
When bitmap scans run out of memory, they degrade to “lossy” mode:
1
2
3
4
5
6
7
-- With artificially low work_mem:
SET work_mem = '64kB';
Bitmap Heap Scan on orders
Recheck Cond: (user_id = ANY ('{1,2,3,...,1000}'::integer[]))
Rows Removed by Index Recheck: 15234
Heap Blocks: exact=1205 lossy=8932
What “Lossy” Means
- exact=1205: PostgreSQL knows exactly which rows to check on these pages
- lossy=8932: PostgreSQL only knows these pages contain matches, must check every row
- Rows Removed by Index Recheck: Extra work from lossy blocks
When lossy, PostgreSQL must recheck conditions on entire pages, not just specific rows.
Solution
Increase work_mem to keep bitmaps in memory:
1
2
3
4
-- For the session:
SET work_mem = '256MB';
-- Or optimize the query to process fewer rows
3. Multiple Batch Hash Joins
The most common performance killer in hash joins:
1
2
3
Hash (cost=2819.00..2819.00 rows=100000 width=14)
(actual time=20.349..20.349 rows=100001 loops=1)
Buckets: 2048 Batches: 128 Memory Usage: 55kB
The Batch Problem Explained
With Batches: 128
, PostgreSQL:
- Splits the 100,000-row table into 128 temporary files
- Processes each batch separately
- Performs 128 write + 128 read operations
That’s 256 disk I/O operations instead of zero!
Calculating Required Memory
Rough formula: rows × average_width × 1.5 / 1024 / 1024 = MB needed
For our example: 100000 × 14 × 1.5 / 1024 / 1024 ≈ 2MB
But with only 64kB work_mem, we got 128 batches!
Solution
1
2
-- Increase work_mem to fit the hash table
SET work_mem = '10MB'; -- Should result in Batches: 1
4. Nested Loops on Large Sets
Nested loops can be catastrophically slow without proper indexes:
1
2
3
4
5
-- BAD: Nested loop without index (hypothetical)
Nested Loop (cost=0.00..50000000.00 rows=1000000 width=229)
-> Seq Scan on users u (cost=0.00..2819.00 rows=100000 width=113)
-> Seq Scan on orders o (cost=0.00..26432.00 rows=10 width=116)
Filter: (user_id = u.id)
The Quadratic Problem
For each of 100,000 users, scan all 1,000,000 orders = 100 billion row comparisons!
Good Nested Loop Example
1
2
3
4
Nested Loop (cost=4.80..56.04 rows=11 width=229)
(actual time=0.025..0.028 rows=4 loops=1)
-> Index Scan using users_pkey on users u (rows=1)
-> Index Scan using idx_orders_user_id on orders o (rows=4)
With indexes: 1 user lookup + 4 order lookups = 5 total operations
When to Worry
- Nested loop with
loops
> 1000 on the inner relation - Seq Scan or Filter on the inner relation
- Total time dominated by the nested loop node
Solutions
- Ensure indexes exist on join columns
- Force a different join if needed:
1
SET enable_nestloop = off; -- Forces hash or merge join
- Rewrite the query to reduce the outer relation size
Practical Analysis Workflow
When faced with a slow query, follow this systematic approach to identify and fix performance issues.
1. Start with Summary Statistics
Always begin by checking the overall timing:
1
2
Planning Time: 101.157 ms -- High planning time!
Execution Time: 91.342 ms
Interpreting Planning Time
- < 1ms: Excellent, simple query
- 1-10ms: Normal for moderate complexity
- 10-50ms: Complex query or first run after restart
- > 50ms: Investigate! Possible causes:
- Outdated table statistics
- Many joins or partitions
- First query after PostgreSQL restart
- Complex view definitions
In our example, 101ms planning suggests running ANALYZE
on the tables.
2. Find the Slowest Node
Work backwards from total execution time to find bottlenecks:
1
2
3
4
-> Sort (actual time=58.565..59.588 rows=29057 loops=3) -- Bottleneck!
Sort Key: (date_trunc('day'::text, created_at))
Sort Method: external merge Disk: 4816kB -- Red flag!
-> Parallel Seq Scan on orders (actual time=0.039..32.511 rows=256251 loops=3)
The sort operation takes 58-59ms while its input (seq scan) only takes 32ms. The “external merge” using disk is our culprit.
3. Check Row Estimates vs Reality
Poor estimates lead to bad plan choices:
1
2
3
4
Seq Scan on orders (cost=0.00..23932.00 rows=510476 width=116) -- Planner estimate
(actual time=0.032..82.451 rows=505588 loops=1) -- Reality
-- Good estimate! Only 1% off
When to Worry About Estimates
- Within 50%: Generally OK
- Off by 2-10x: May cause suboptimal plans
- Off by >10x: Definitely causing problems
Common fixes:
1
2
3
4
5
6
-- Update table statistics
ANALYZE orders;
-- Increase statistics target for specific columns
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
4. Analyze I/O Patterns
Use BUFFERS option to see cache behavior:
1
2
3
4
5
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM users WHERE email = 'user1000@example.com';
Index Scan using idx_users_email on users
Buffers: shared hit=3 read=1
Buffer Metrics Decoded
- shared hit: Pages found in PostgreSQL’s cache (fast)
- shared read: Pages read from disk (slow)
- shared dirtied: Pages modified in cache
- shared written: Pages written to disk
Ideal pattern: High hit ratio (hits / (hits + reads))
5. Look for Memory Pressure
Memory-constrained operations are common bottlenecks:
1
2
3
4
5
6
7
8
9
-- Red flags to watch for:
Sort Method: external merge Disk: 4816kB -- Sorting on disk
Hash Batches: 128 -- Hash join spilling to disk
Heap Blocks: lossy=8932 -- Bitmap degraded to lossy
-- Good signs:
Sort Method: quicksort Memory: 25kB -- In-memory sort
Hash Batches: 1 -- Hash table fits in memory
Heap Blocks: exact=1205 -- Precise bitmap
Optimization Checklist
When analyzing slow queries, work through this checklist systematically:
1. Missing Indexes
Symptom: High “Rows Removed by Filter” in Sequential Scans
1
Rows Removed by Filter: 461378 -- Major red flag!
Fix: Add indexes on filter columns, especially for selective queries
2. Outdated Statistics
Symptom: Row estimates off by >10x
1
rows=1000 (actual rows=50000) -- 50x underestimate!
Fix: Run ANALYZE, increase statistics targets, or enable auto-analyze
3. Insufficient Memory
Symptom: Disk-based operations
Sort Method: external merge
Hash Batches: > 1
Heap Blocks: lossy
Fix: Increase work_mem or optimize queries to process less data
4. Poor Join Order
Symptom: Large intermediate result sets
1
2
-- Bad: Join produces 10M rows, then filters to 100
-- Good: Filter first, then join 100 rows
Fix: Add selective conditions, rewrite query, or adjust join_collapse_limit
5. Missing Parallelism
Symptom: Large scans without parallel workers
1
Workers Planned: 0 -- On a 10GB table scan
Fix: Check max_parallel_workers settings and table size thresholds
6. Index Correlation Issues
Symptom: Index scan slower than expected
1
-- Index scan taking longer than seq scan would
Fix: CLUSTER table on index, or use BRIN indexes for naturally ordered data
Tools and Settings
Essential EXPLAIN Options
1
2
-- The full diagnostic toolkit:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, WAL) SELECT ...
Option Reference
- ANALYZE: Executes query and shows real timings
- BUFFERS: Shows cache hits/misses and I/O statistics
- VERBOSE: Displays full output column lists and schema info
- SETTINGS: Shows non-default configurations affecting the plan
- WAL: Shows Write-Ahead Log generation (for write queries)
Progressive Analysis Example
1
2
3
4
5
6
7
8
9
10
11
-- Step 1: Basic plan structure
EXPLAIN SELECT ...
-- Step 2: See actual execution
EXPLAIN ANALYZE SELECT ...
-- Step 3: Add I/O analysis
EXPLAIN (ANALYZE, BUFFERS) SELECT ...
-- Step 4: Full diagnostics for complex issues
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) SELECT ...
Key Configuration Parameters
Memory Settings
1
2
3
4
5
6
7
8
-- Check current values
SHOW work_mem; -- Default: 4MB
SHOW shared_buffers; -- Default: 128MB
SHOW effective_cache_size; -- Default: 4GB
-- Session-level adjustments
SET work_mem = '256MB'; -- For sorts, hashes, CTEs
SET temp_buffers = '32MB'; -- For temporary tables
Cost Parameters
1
2
3
4
5
6
7
8
9
10
-- Disk I/O costs (in arbitrary units)
SHOW seq_page_cost; -- Default: 1.0
SHOW random_page_cost; -- Default: 4.0 (SSD: consider 1.1)
-- CPU costs
SHOW cpu_tuple_cost; -- Default: 0.01
SHOW cpu_operator_cost; -- Default: 0.0025
-- Adjust for SSD storage:
SET random_page_cost = 1.1;
Parallelism Controls
1
2
3
4
5
6
-- Parallel query settings
SHOW max_parallel_workers_per_gather; -- Default: 2
SHOW min_parallel_table_scan_size; -- Default: 8MB
-- Enable more parallelism
SET max_parallel_workers_per_gather = 4;
Query Hints (Use Sparingly)
1
2
3
4
5
6
7
-- Force/prevent specific strategies
SET enable_seqscan = off; -- Force index usage
SET enable_nestloop = off; -- Force hash/merge joins
SET enable_hashjoin = off; -- Force merge/nested joins
-- Remember to reset!
RESET enable_seqscan;
Conclusion
Query optimization is an iterative process. Start by identifying the biggest bottleneck, fix it, then re-analyze. Often, fixing one issue reveals others that were hidden.
The key insights to remember:
- Most performance problems stem from missing indexes or insufficient memory
- The planner makes mistakes when statistics are outdated
- Small configuration changes can yield dramatic improvements
- Always measure before and after optimization
With practice, you’ll develop an intuition for common patterns and their solutions. The query planner is sophisticated but not magic—understanding its decisions empowers you to guide it toward optimal plans.
Ready to master PostgreSQL performance? Subscribe for more deep dives into database optimization, query tuning, and performance best practices.