Query Optimizer Troubleshooting Guide
Query Optimizer Troubleshooting Guide
Version: 7.0 Status: Production Ready Last Updated: 2026-01-04
Table of Contents
- Identifying Bottlenecks
- Index Recommendations
- Query Rewriting Tips
- Common Anti-Patterns
- Performance Diagnostics
Identifying Bottlenecks
Step 1: Find Slow Queries
-- Using pg_stat_statements (if enabled)SELECT query, calls, total_exec_time / 1000 as total_seconds, mean_exec_time as avg_ms, rowsFROM pg_stat_statementsORDER BY total_exec_time DESCLIMIT 10;
/*Example output:query | calls | total_seconds | avg_ms | rows-----------------------------------------+-------+---------------+--------+------SELECT * FROM orders WHERE customer... | 50000 | 1250.5 | 25.01 | 10SELECT o.*, c.name FROM orders o JO... | 10000 | 850.2 | 85.02 | 1000*/-- Check currently running slow queriesSELECT pid, now() - pg_stat_activity.query_start AS duration, query, stateFROM pg_stat_activityWHERE (now() - pg_stat_activity.query_start) > interval '5 seconds' AND state = 'active'ORDER BY duration DESC;Step 2: Analyze the Query Plan
-- Full analysis with all detailsEXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)SELECT * FROM orders WHERE customer_id = 123;
/*Key metrics to examine:
1. actual time vs estimated time - Large difference = stale statistics
2. rows vs estimated rows - 10x+ difference = cardinality error
3. Buffers: shared read vs hit - high read = cold cache or too much data
4. Sort Method: external merge - spilling to disk = increase work_mem*/Step 3: Use Why-Not Analysis
EXPLAIN ANALYZE (WHY_NOT)SELECT * FROM orders WHERE customer_id = 123;
/*WHY_NOT ANALYSIS================
UNUSED INDEXES (1): idx_orders_customer_date Reason: Statistics stale (45 days old) Suggestion: ANALYZE orders;
CARDINALITY MISMATCH: Estimated: 10 rows Actual: 5000 rows Error: 500x underestimate Cause: Outdated statistics
STATISTICS WARNINGS: Table: orders Last analyzed: 45 days ago Rows modified: 35% Action: ANALYZE orders;*/Common Bottleneck Patterns
| Symptom | Likely Cause | Solution |
|---|---|---|
| Seq Scan on large table | Missing index | Create index |
| High “Rows Removed by Filter” | Low selectivity | More selective predicate |
| Actual >> Estimated rows | Stale statistics | ANALYZE table |
| ”external merge” sort | Insufficient memory | Increase work_mem |
| Nested Loop with large outer | Wrong join type | Check indexes, memory |
| Many “loops” in plan | Correlated subquery | Rewrite as JOIN |
Index Recommendations
Diagnosing Missing Indexes
-- Find sequential scans on large tablesSELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan, seq_tup_read / GREATEST(seq_scan, 1) as avg_seq_rowsFROM pg_stat_user_tablesWHERE seq_scan > 0 AND seq_tup_read > 100000ORDER BY seq_tup_read DESC;
/*If a table has:- High seq_scan count- High seq_tup_read- Low or zero idx_scan
It likely needs an index on commonly filtered columns.*/Index Advisor
-- HeliosDB Index AdvisorEXPLAIN ADVISORSELECT * FROM ordersWHERE customer_id = 123 AND order_date > '2025-01-01';
/*INDEX RECOMMENDATIONS=====================
1. Composite Index (Recommended) CREATE INDEX idx_orders_cust_date ON orders(customer_id, order_date);
Impact: 95% cost reduction Reason: Covers both WHERE conditions
2. Partial Index (Alternative) CREATE INDEX idx_orders_recent ON orders(customer_id) WHERE order_date > '2025-01-01';
Impact: 90% cost reduction Size: Smaller than full index Use if: Most queries filter recent dates*/Choosing the Right Index Type
-- B-Tree: Default, best for most casesCREATE INDEX idx_orders_date ON orders(order_date);-- Supports: =, <, >, <=, >=, BETWEEN, IN, IS NULL-- Use for: Most columns
-- Hash: Equality only, slightly fasterCREATE INDEX idx_users_email ON users USING HASH(email);-- Supports: = only-- Use for: Exact match lookups only
-- GiST: Geometric and range typesCREATE INDEX idx_geo ON locations USING GIST(coordinates);-- Supports: Spatial queries, ranges, full-text-- Use for: Geographic data, IP ranges
-- GIN: Arrays, JSONB, full-textCREATE INDEX idx_tags ON products USING GIN(tags);-- Supports: Array containment, JSONB operators-- Use for: Multi-value columns
-- BRIN: Very large sequential tablesCREATE INDEX idx_logs_time ON logs USING BRIN(timestamp);-- Supports: Range queries on ordered data-- Use for: Append-only tables (time-series)Composite Index Column Order
-- RULE: Put equality columns BEFORE range columns
-- Query pattern:SELECT * FROM ordersWHERE customer_id = 123 -- Equality AND order_date > '2025-01-01'; -- Range
-- GOOD: Equality firstCREATE INDEX idx_good ON orders(customer_id, order_date);-- Uses index fully
-- BAD: Range firstCREATE INDEX idx_bad ON orders(order_date, customer_id);-- Can only use first column for range scan-- For multiple columns, order by selectivity
-- Most selective column firstCREATE INDEX idx_selective ON orders( email, -- Unique (selectivity ~0.001%) status, -- 5 values (selectivity ~20%) created_date -- 365 values (selectivity ~0.3%));Covering Indexes
-- Query accesses specific columns frequentlySELECT customer_id, order_date, amountFROM ordersWHERE customer_id = 123;
-- Covering index includes all needed columnsCREATE INDEX idx_covering ON orders(customer_id)INCLUDE (order_date, amount);
-- Result: Index-Only Scan (no table access)EXPLAIN SELECT customer_id, order_date, amountFROM orders WHERE customer_id = 123;
/*Index Only Scan using idx_covering on orders Index Cond: (customer_id = 123) Heap Fetches: 0 <-- No table access!*/Partial Indexes
-- Only 5% of orders are 'pending'SELECT * FROM orders WHERE status = 'pending';
-- Full index (indexes all 1M rows)CREATE INDEX idx_status ON orders(status);-- Size: 25MB
-- Partial index (indexes only 50K pending rows)CREATE INDEX idx_pending ON orders(order_date)WHERE status = 'pending';-- Size: 1.5MB, 16x smaller!
-- Query uses partial indexEXPLAIN SELECT * FROM ordersWHERE status = 'pending' AND order_date > '2025-01-01';
/*Index Scan using idx_pending on orders Index Cond: (order_date > '2025-01-01') Filter: (status = 'pending') -- Already satisfied by index condition*/Removing Unused Indexes
-- Find unused indexesSELECT schemaname, relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) as sizeFROM pg_stat_user_indexesWHERE idx_scan = 0 AND indexrelname NOT LIKE 'pg_%'ORDER BY pg_relation_size(indexrelid) DESC;
/*Unused indexes:- Waste storage space- Slow down INSERT/UPDATE/DELETE- Should be reviewed and potentially dropped*/
-- Drop after confirming not neededDROP INDEX idx_unused_index;Query Rewriting Tips
Replace Correlated Subqueries with JOINs
-- SLOW: Correlated subquery (executes per row)SELECT o.id, o.amount, (SELECT c.name FROM customers c WHERE c.id = o.customer_id) as customer_nameFROM orders o;
-- FAST: LEFT JOIN (single hash join)SELECT o.id, o.amount, c.name as customer_nameFROM orders oLEFT JOIN customers c ON o.customer_id = c.id;
/*Performance difference: 100x faster for large tablesReason: JOIN builds hash table once, subquery runs N times*/Use EXISTS Instead of COUNT
-- SLOW: Count all matching rowsSELECT * FROM customers cWHERE (SELECT COUNT(*) FROM orders WHERE customer_id = c.id) > 0;
-- FAST: Stop at first matchSELECT * FROM customers cWHERE EXISTS (SELECT 1 FROM orders WHERE customer_id = c.id);
/*EXISTS stops after finding first matchCOUNT must scan all matching rows*/Replace DISTINCT with GROUP BY
-- SLOW: DISTINCT on large resultSELECT DISTINCT customer_id, order_dateFROM orders;
-- FASTER: GROUP BY (same result, often better plan)SELECT customer_id, order_dateFROM ordersGROUP BY customer_id, order_date;
/*GROUP BY can use hash aggregationDISTINCT may require sort*/Avoid SELECT *
-- SLOW: Fetches all columnsSELECT * FROM orders WHERE customer_id = 123;
-- FAST: Fetch only needed columnsSELECT id, order_date, amount FROM orders WHERE customer_id = 123;
/*Benefits:- Less I/O (fewer bytes read)- Better cache utilization- May enable index-only scan*/Push Filters Down
-- SLOW: Filter after joinSELECT o.*, c.nameFROM orders oJOIN customers c ON o.customer_id = c.idWHERE o.order_date > '2025-01-01';
-- FASTER: Filter in subquery (explicit pushdown)SELECT o.*, c.nameFROM (SELECT * FROM orders WHERE order_date > '2025-01-01') oJOIN customers c ON o.customer_id = c.id;
/*Note: Modern optimizers do this automatically, but explicitsubquery ensures it happens. Useful for views and CTEs.*/Use UNION ALL Instead of UNION
-- SLOW: UNION removes duplicates (requires sort)SELECT name FROM customers_2024UNIONSELECT name FROM customers_2025;
-- FAST: UNION ALL keeps all rows (no dedup)SELECT name FROM customers_2024UNION ALLSELECT name FROM customers_2025;
/*Use UNION ALL when:- Duplicates are acceptable- Source tables don't overlap- You'll filter/aggregate afterward*/Limit Subquery Results
-- SLOW: Subquery returns many rowsSELECT * FROM productsWHERE category_id IN (SELECT id FROM categories WHERE type = 'active');
-- FASTER: Use EXISTS for existence checkSELECT * FROM products pWHERE EXISTS ( SELECT 1 FROM categories c WHERE c.id = p.category_id AND c.type = 'active');
-- OR: If you need the category data, use JOINSELECT p.*FROM products pJOIN categories c ON p.category_id = c.idWHERE c.type = 'active';Common Anti-Patterns
Anti-Pattern 1: Function on Indexed Column
-- BAD: Function prevents index useSELECT * FROM users WHERE LOWER(email) = 'john@example.com';
/*Seq Scan on users Filter: (lower(email) = 'john@example.com')
Problem: Index on 'email' can't be used because of LOWER()*/
-- SOLUTION 1: Create functional indexCREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- SOLUTION 2: Store normalized dataALTER TABLE users ADD COLUMN email_lower VARCHAR(255);UPDATE users SET email_lower = LOWER(email);CREATE INDEX idx_users_email_lower ON users(email_lower);Anti-Pattern 2: Leading Wildcard
-- BAD: Leading wildcard prevents index useSELECT * FROM products WHERE name LIKE '%laptop%';
/*Seq Scan on products Filter: (name ~~ '%laptop%')
Problem: B-tree index can't help with leading wildcard*/
-- SOLUTION: Use full-text searchCREATE INDEX idx_products_fts ON products USING GIN(to_tsvector('english', name));
SELECT * FROM productsWHERE to_tsvector('english', name) @@ to_tsquery('english', 'laptop');Anti-Pattern 3: OR with Different Columns
-- BAD: OR prevents index optimizationSELECT * FROM ordersWHERE customer_id = 123 OR region = 'US';
/*Seq Scan on orders Filter: ((customer_id = 123) OR (region = 'US'))
Problem: Optimizer can't use single index for both conditions*/
-- SOLUTION: Rewrite as UNIONSELECT * FROM orders WHERE customer_id = 123UNIONSELECT * FROM orders WHERE region = 'US';
/*Append -> Index Scan using idx_orders_customer -> Index Scan using idx_orders_region
Each branch uses its optimal index*/Anti-Pattern 4: Implicit Type Conversion
-- BAD: Type mismatch causes conversionSELECT * FROM users WHERE id = '123'; -- id is INTEGER
/*Seq Scan on users Filter: ((id)::text = '123')
Problem: Integer column cast to text, index unusable*/
-- SOLUTION: Use correct typeSELECT * FROM users WHERE id = 123; -- No quotes
/*Index Scan using users_pkey Index Cond: (id = 123)*/Anti-Pattern 5: NOT IN with NULLs
-- BAD: NOT IN with NULLs gives unexpected resultsSELECT * FROM ordersWHERE customer_id NOT IN (SELECT id FROM deleted_customers);
/*If deleted_customers contains NULL, NO rows are returned!Also performs poorly (anti-join not optimized)*/
-- SOLUTION: Use NOT EXISTSSELECT * FROM orders oWHERE NOT EXISTS ( SELECT 1 FROM deleted_customers dc WHERE dc.id = o.customer_id);
/*Hash Anti Join (efficient) -> Seq Scan on orders -> Hash -> Seq Scan on deleted_customers
Handles NULLs correctly and performs better*/Anti-Pattern 6: ORDER BY in Subquery
-- BAD: Sorting in subquery is usually ignoredSELECT * FROM ( SELECT * FROM orders ORDER BY order_date DESC) subWHERE customer_id = 123;
/*Subquery ORDER BY is discarded unless LIMIT is presentWasted sort operation*/
-- SOLUTION: Sort at the outer levelSELECT * FROM ordersWHERE customer_id = 123ORDER BY order_date DESC;Anti-Pattern 7: OFFSET for Pagination
-- BAD: Large OFFSET is slowSELECT * FROM ordersORDER BY order_date DESCOFFSET 100000 LIMIT 10;
/*Must scan and discard 100,000 rows before returning 10Gets slower as OFFSET increases*/
-- SOLUTION: Keyset paginationSELECT * FROM ordersWHERE order_date < '2025-06-15 10:30:00' -- Last seen valueORDER BY order_date DESCLIMIT 10;
/*Uses index directly, constant time regardless of page number*/Performance Diagnostics
Memory Analysis
-- Check for disk spillsEXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders ORDER BY customer_id;
/*Sort (actual time=1500.5..2000.3 ms) Sort Method: external merge Disk: 250000kB <-- PROBLEM!*/
-- Solution: Increase work_memSET work_mem = '512MB';
/*Sort (actual time=200.5..300.3 ms) Sort Method: quicksort Memory: 150000kB <-- Fixed!*/Buffer Cache Analysis
-- Analyze buffer usageEXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders WHERE customer_id = 123;
/*Index Scan using idx_orders_customer Buffers: shared hit=5 read=2 <-- 2 pages from disk
High 'read' vs 'hit' indicates:- Cold cache (normal after restart)- Working set larger than shared_buffers- Table/index fragmentation*/
-- Check shared_buffers sizeSHOW shared_buffers; -- e.g., 1GB
-- Check cache hit ratioSELECT sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) as cache_hit_ratioFROM pg_statio_user_tables;
-- Target: > 99% cache hit ratioLock Contention Analysis
-- Check for locksSELECT blocked_locks.pid AS blocked_pid, blocked_activity.query AS blocked_query, blocking_locks.pid AS blocking_pid, blocking_activity.query AS blocking_queryFROM pg_catalog.pg_locks blocked_locksJOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pidJOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation AND blocking_locks.pid != blocked_locks.pidJOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pidWHERE NOT blocked_locks.granted;
/*If queries are waiting on locks:- Long-running transactions blocking others- Deadlock potential- Consider query optimization or schema redesign*/Query Plan Regression Detection
-- Compare plan costs over timeWITH current_plan AS ( SELECT 'current' as version, 50.0 as cost -- From EXPLAIN),previous_plan AS ( SELECT 'previous' as version, 30.0 as cost -- From historical data)SELECT current_plan.cost as current_cost, previous_plan.cost as previous_cost, (current_plan.cost - previous_plan.cost) / previous_plan.cost * 100 as regression_pctFROM current_plan, previous_plan;
/*If regression_pct > 20%, investigate:- Statistics changed- Data distribution changed- Index added/removed- Configuration changed*/Diagnostic Queries Summary
-- 1. Find queries needing optimizationSELECT query, calls, mean_exec_timeFROM pg_stat_statementsORDER BY mean_exec_time DESC LIMIT 10;
-- 2. Find tables needing ANALYZESELECT relname, last_analyze, n_mod_since_analyzeFROM pg_stat_user_tablesWHERE n_mod_since_analyze > 1000;
-- 3. Find unused indexesSELECT indexrelname, idx_scanFROM pg_stat_user_indexesWHERE idx_scan = 0;
-- 4. Check cache hit ratioSELECT schemaname, relname, heap_blks_hit::float / (heap_blks_hit + heap_blks_read) as hit_ratioFROM pg_statio_user_tablesWHERE heap_blks_read > 0ORDER BY hit_ratio;
-- 5. Find table bloatSELECT relname, n_dead_tup, n_live_tup, n_dead_tup::float / GREATEST(n_live_tup, 1) as dead_ratioFROM pg_stat_user_tablesWHERE n_dead_tup > 10000ORDER BY dead_ratio DESC;Troubleshooting Checklist
Before Optimizing
- Identified the slow query
- Collected EXPLAIN ANALYZE output
- Checked actual vs estimated rows
- Verified statistics are current
- Checked buffer hit ratio
Index Issues
- Reviewed unused indexes
- Checked for missing indexes
- Verified index column order
- Considered covering indexes
- Checked for index bloat
Query Issues
- Removed SELECT *
- Replaced correlated subqueries
- Used EXISTS vs COUNT
- Fixed function-on-column issues
- Addressed OR conditions
Configuration Issues
- Verified work_mem adequate
- Checked shared_buffers size
- Reviewed random_page_cost
- Verified parallel settings
- Checked connection limits
Related Documentation
Query Optimizer Troubleshooting - Diagnose and fix any performance issue.