Query Optimizer User Guide
Query Optimizer User Guide
Version: 7.0 Status: Production Ready Last Updated: 2026-01-04
Table of Contents
- Optimizer Hints
- Statistics Management
- Join Order Optimization
- Subquery Optimization
- CTE Optimization
- Window Function Optimization
- Advanced Configuration
Optimizer Hints
Optimizer hints allow you to influence query planning decisions when the optimizer makes suboptimal choices.
Index Hints
-- Force use of specific indexSELECT /*+ IndexScan(orders idx_orders_date) */ * FROM ordersWHERE order_date > '2025-01-01';
/*Index Scan using idx_orders_date on orders Index Cond: (order_date > '2025-01-01')
Without hint: Optimizer might choose sequential scanWith hint: Forces index scan on specified index*/-- Force index-only scanSELECT /*+ IndexOnlyScan(orders idx_orders_covering) */ customer_id, order_dateFROM ordersWHERE customer_id = 123;
/*Index Only Scan using idx_orders_covering on orders Index Cond: (customer_id = 123) Heap Fetches: 0*/-- Prevent index usage (force sequential scan)SELECT /*+ SeqScan(orders) */ * FROM ordersWHERE customer_id = 123;
/*Seq Scan on orders Filter: (customer_id = 123)
Use case: Testing, comparing plan costs*/Join Hints
-- Force hash joinSELECT /*+ HashJoin(o c) */ o.*, c.nameFROM orders oJOIN customers c ON o.customer_id = c.id;
/*Hash Join Hash Cond: (o.customer_id = c.id)*/-- Force nested loop joinSELECT /*+ NestLoop(o c) */ o.*, c.nameFROM orders oJOIN customers c ON o.customer_id = c.idWHERE o.id = 12345;
/*Nested Loop -> Index Scan on orders o -> Index Scan on customers c
Use case: When outer table is very small*/-- Force merge joinSELECT /*+ MergeJoin(o c) */ o.*, c.nameFROM orders oJOIN customers c ON o.customer_id = c.idORDER BY o.customer_id;
/*Merge Join Merge Cond: (o.customer_id = c.id) -> Index Scan on orders o -> Index Scan on customers c*/Join Order Hints
-- Specify join orderSELECT /*+ Leading(c o oi) */ c.name, o.order_date, oi.product_idFROM customers cJOIN orders o ON c.id = o.customer_idJOIN order_items oi ON o.id = oi.order_idWHERE c.status = 'active';
/*Execution Order:1. Scan customers (filtered by status)2. Join with orders3. Join with order_items
Without hint: Optimizer chooses based on costWith hint: Follows specified order*/-- Alternative leading syntaxSELECT /*+ Leading((c o) oi) */ c.name, o.order_date, oi.product_idFROM customers cJOIN orders o ON c.id = o.customer_idJOIN order_items oi ON o.id = oi.order_id;
/*Parentheses control grouping:- First join c with o- Then join result with oi*/Parallel Execution Hints
-- Force parallel executionSELECT /*+ Parallel(orders 8) */ SUM(amount)FROM orders;
/*Gather Workers Planned: 8 -> Partial Aggregate -> Parallel Seq Scan on orders*/-- Disable parallel executionSELECT /*+ NoParallel */ SUM(amount)FROM orders;
/*Aggregate -> Seq Scan on orders
Use case: Reduce resource usage, debugging*/Multiple Hints
-- Combine multiple hintsSELECT /*+ Leading(c o) HashJoin(c o) Parallel(o 4) */ c.name, SUM(o.amount)FROM customers cJOIN orders o ON c.id = o.customer_idGROUP BY c.name;
/*Gather Workers Planned: 4 -> Partial HashAggregate -> Parallel Hash Join Hash Cond: (o.customer_id = c.id) -> Parallel Seq Scan on orders o -> Parallel Hash -> Parallel Seq Scan on customers c*/Statistics Management
Accurate statistics are essential for optimal query plans.
Understanding Statistics
-- View table statisticsSELECT relname AS table_name, n_live_tup AS live_rows, n_dead_tup AS dead_rows, last_vacuum, last_analyze, last_autoanalyzeFROM pg_stat_user_tablesWHERE relname = 'orders';
/*table_name | live_rows | dead_rows | last_analyze-----------+-----------+-----------+---------------------orders | 1000000 | 5000 | 2025-12-15 02:00:00*/-- View column statisticsSELECT attname AS column_name, null_frac AS null_fraction, n_distinct AS distinct_values, avg_width AS avg_bytesFROM pg_statsWHERE tablename = 'orders';
/*column_name | null_fraction | distinct_values | avg_bytes-------------+---------------+-----------------+-----------id | 0 | -1 | 8customer_id | 0 | 10000 | 4order_date | 0 | 365 | 4amount | 0 | 50000 | 8status | 0 | 5 | 10*/Collecting Statistics
-- Analyze single tableANALYZE orders;
-- Analyze with verbose outputANALYZE VERBOSE orders;/*INFO: analyzing "public.orders"INFO: "orders": scanned 30000 of 30000 pagesINFO: "orders": 1000000 rows in sample, 1000000 estimated total rows*/
-- Analyze specific columnsANALYZE orders (customer_id, order_date);
-- Sample-based analysis for very large tablesANALYZE (SAMPLE 10 PERCENT) huge_table;Extended Statistics
For correlated columns, extended statistics improve cardinality estimates.
-- Identify correlation issueEXPLAIN ANALYZESELECT * FROM salesWHERE region = 'US' AND product_category = 'Electronics';
/*Seq Scan on sales (cost=0.00..50000.00 rows=100 width=50) (actual time=0.5..250.8 ms rows=50000 loops=1)
Problem: Estimated 100 rows, actual 50000 rows!Cause: Columns are correlated but optimizer doesn't know*/-- Create extended statisticsCREATE STATISTICS sales_region_category (dependencies)ON region, product_category FROM sales;
-- Or with ndistinct for multi-column distinct countsCREATE STATISTICS sales_region_category_ndist (ndistinct)ON region, product_category FROM sales;
-- Collect the extended statisticsANALYZE sales;
-- Verify improved estimateEXPLAIN ANALYZESELECT * FROM salesWHERE region = 'US' AND product_category = 'Electronics';
/*Seq Scan on sales (cost=0.00..50000.00 rows=48000 width=50) (actual time=0.5..250.8 ms rows=50000 loops=1)
Improvement: Now estimates 48000 rows (much closer to actual 50000)*/-- View extended statisticsSELECT stxname AS stat_name, stxkeys AS columns, stxkind AS stat_typeFROM pg_statistic_extWHERE stxname = 'sales_region_category';Statistics Freshness Monitoring
-- Check statistics ageSELECT schemaname, relname, last_analyze, EXTRACT(days FROM now() - last_analyze) AS days_since_analyze, n_live_tup, n_mod_since_analyzeFROM pg_stat_user_tablesWHERE EXTRACT(days FROM now() - last_analyze) > 7 OR last_analyze IS NULLORDER BY n_mod_since_analyze DESC;
/*Identifies tables needing ANALYZE:- Not analyzed in last 7 days- High modification count since last analyze*/-- Automate statistics collectionALTER SYSTEM SET autovacuum_analyze_threshold = 50;ALTER SYSTEM SET autovacuum_analyze_scale_factor = 0.1;SELECT pg_reload_conf();
/*Triggers auto-analyze when: modifications > 50 + (0.1 * table_size)
For 10000 row table: triggers at 1050 modifications*/Join Order Optimization
The optimizer evaluates different join orders to find the lowest-cost plan.
Understanding Join Order
-- Three-table joinEXPLAIN (VERBOSE, COSTS)SELECT c.name, o.order_date, p.product_nameFROM customers cJOIN orders o ON c.id = o.customer_idJOIN products p ON o.product_id = p.idWHERE c.status = 'active';
/*Possible join orders:1. (c JOIN o) JOIN p - customers first2. (c JOIN p) JOIN o - unlikely (no direct relation)3. (o JOIN c) JOIN p - orders first4. (o JOIN p) JOIN c - orders with products first...
Optimizer evaluates costs and picks lowest*/Analyzing Join Order Decisions
EXPLAIN ANALYZE (WHY_NOT)SELECT c.name, o.order_date, p.product_nameFROM customers cJOIN orders o ON c.id = o.customer_idJOIN products p ON o.product_id = p.idWHERE c.status = 'active';
/*Optimizer Decision: Join Order
CHOSEN: (customers JOIN orders) JOIN products Reasoning: - customers filtered to 1000 rows (10% active) - Joining first reduces intermediate result - products joined last (lookup by id)
REJECTED: (orders JOIN products) JOIN customers Cost: 5x higher Reason: orders has 1M rows, no initial filter Large intermediate result
REJECTED: (customers JOIN products) JOIN orders Cost: 10x higher Reason: No direct join condition, creates cartesian product*/Controlling Join Order
-- Reduce optimization search spaceSET join_collapse_limit = 8; -- DefaultSET from_collapse_limit = 8; -- Default
-- For very complex queries, reduce to speed up planningSET join_collapse_limit = 4;
-- Disable reordering (use query order exactly)SET join_collapse_limit = 1;-- Force specific join order with explicit syntaxSELECT c.name, o.order_dateFROM customers cJOIN ( SELECT * FROM orders WHERE order_date > '2025-01-01') o ON c.id = o.customer_id;
/*Forces orders to be scanned/filtered before join*/Subquery Optimization
Correlated vs Non-Correlated Subqueries
-- NON-CORRELATED (executed once)SELECT * FROM ordersWHERE customer_id IN ( SELECT id FROM customers WHERE status = 'active');
/*Hash Semi Join -> Seq Scan on orders -> Hash -> Seq Scan on customers Filter: (status = 'active')
Good: Subquery executed once, result cached*/-- CORRELATED (executed per row - SLOW!)SELECT o.id, o.amount, (SELECT c.name FROM customers c WHERE c.id = o.customer_id)FROM orders o;
/*Seq Scan on orders o SubPlan 1 -> Index Scan on customers c Index Cond: (id = o.customer_id)
Problem: Subquery runs for EACH order rowFor 1M orders: 1M subquery executions!*/Rewriting Subqueries as Joins
-- BEFORE: Correlated subquerySELECT o.id, o.amount, (SELECT c.name FROM customers c WHERE c.id = o.customer_id)FROM orders o;-- 1M subquery executions, very slow
-- AFTER: JOIN (much faster)SELECT o.id, o.amount, c.nameFROM orders oLEFT JOIN customers c ON o.customer_id = c.id;
/*Hash Left Join -> Seq Scan on orders o -> Hash -> Seq Scan on customers c
1 hash build + 1M probes = much faster*/EXISTS vs IN Optimization
-- IN with large subquerySELECT * FROM ordersWHERE customer_id IN ( SELECT id FROM customers WHERE region = 'US');
-- EXISTS (often more efficient)SELECT * FROM orders oWHERE EXISTS ( SELECT 1 FROM customers c WHERE c.id = o.customer_id AND c.region = 'US');
/*Both produce Hash Semi Join, but EXISTS can short-circuit:- Stops searching after first match- More efficient when many matches exist*/Subquery to Lateral Join
-- BEFORE: Subquery returning multiple columnsSELECT o.id, (SELECT c.name FROM customers c WHERE c.id = o.customer_id), (SELECT c.email FROM customers c WHERE c.id = o.customer_id)FROM orders o;-- 2 subqueries per row!
-- AFTER: LATERAL joinSELECT o.id, c.name, c.emailFROM orders oLEFT JOIN LATERAL ( SELECT name, email FROM customers WHERE id = o.customer_id) c ON true;
/*Single lookup per row, returns multiple columns*/CTE Optimization
Common Table Expressions (CTEs) provide readability but require careful optimization.
CTE Materialization
-- Materialized CTE (computed once, stored in memory)WITH active_customers AS MATERIALIZED ( SELECT id, name FROM customers WHERE status = 'active')SELECT o.*, ac.nameFROM orders oJOIN active_customers ac ON o.customer_id = ac.id;
/*CTE Scan on active_customers -> CTE active_customers -> Seq Scan on customers Filter: (status = 'active')
Use MATERIALIZED when:- CTE referenced multiple times- Subquery is expensive- Want to force optimization barrier*/-- Non-materialized CTE (inlined into main query)WITH active_customers AS NOT MATERIALIZED ( SELECT id, name FROM customers WHERE status = 'active')SELECT o.*, ac.nameFROM orders oJOIN active_customers ac ON o.customer_id = ac.id;
/*Hash Join -> Seq Scan on orders -> Hash -> Seq Scan on customers Filter: (status = 'active')
CTE is "flattened" - same as writing JOIN directlyUse NOT MATERIALIZED when:- CTE referenced once- Want optimizer to see full picture*/Recursive CTEs
-- Recursive CTE for hierarchical dataWITH RECURSIVE org_tree AS ( -- Base case: top-level managers SELECT id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL
UNION ALL
-- Recursive case: subordinates SELECT e.id, e.name, e.manager_id, ot.level + 1 FROM employees e JOIN org_tree ot ON e.manager_id = ot.id WHERE ot.level < 10 -- Depth limit!)SELECT * FROM org_tree;
/*CTE Scan on org_tree -> Recursive Union -> Seq Scan on employees Filter: (manager_id IS NULL) -> Hash Join -> WorkTable Scan on org_tree ot -> Hash -> Seq Scan on employees e
Optimization tips:1. Add depth limit (WHERE level < N)2. Index on manager_id for join3. Use UNION ALL (not UNION) to avoid dedup*/CTE Performance Tips
-- GOOD: Single-use CTE, let optimizer inlineWITH filtered_orders AS NOT MATERIALIZED ( SELECT * FROM orders WHERE date > '2025-01-01')SELECT * FROM filtered_orders WHERE amount > 1000;
-- GOOD: Multi-use CTE, materializeWITH order_stats AS MATERIALIZED ( SELECT customer_id, COUNT(*) as cnt, SUM(amount) as total FROM orders GROUP BY customer_id)SELECT * FROM order_stats WHERE cnt > 10UNION ALLSELECT * FROM order_stats WHERE total > 10000;
-- BAD: Unnecessary CTE barrierWITH simple_filter AS ( -- No MATERIALIZED keyword SELECT * FROM orders WHERE status = 'pending')SELECT * FROM simple_filter;-- Just write: SELECT * FROM orders WHERE status = 'pending'Window Function Optimization
Understanding Window Function Execution
EXPLAIN ANALYZESELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) as running_totalFROM orders;
/*WindowAgg (cost=50000.00..75000.00 rows=1000000 width=40) -> Sort (cost=50000.00..52500.00 rows=1000000 width=32) Sort Key: customer_id, order_date -> Seq Scan on orders
Execution flow:1. Scan all rows2. Sort by window PARTITION and ORDER3. Compute window aggregate*/Index Optimization for Windows
-- Create index matching window specificationCREATE INDEX idx_orders_windowON orders (customer_id, order_date);
EXPLAIN ANALYZESELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) as running_totalFROM orders;
/*WindowAgg -> Index Scan using idx_orders_window on orders
No sort needed! Index provides correct order.*/Multiple Windows Optimization
-- Multiple windows with same PARTITIONSELECT id, customer_id, SUM(amount) OVER w as total, AVG(amount) OVER w as avg, COUNT(*) OVER w as cntFROM ordersWINDOW w AS (PARTITION BY customer_id);
/*Single WindowAgg node computes all functionsMore efficient than separate window specs*/-- Different partitions require separate passesSELECT id, SUM(amount) OVER (PARTITION BY customer_id) as cust_total, SUM(amount) OVER (PARTITION BY region) as region_totalFROM orders;
/*WindowAgg -- For region partition -> Sort Sort Key: region -> WindowAgg -- For customer partition -> Sort Sort Key: customer_id -> Seq Scan
Two sorts required - optimize by minimizing different partitions*/Frame Clause Optimization
-- Default frame (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)SUM(amount) OVER (PARTITION BY cust ORDER BY date)
-- Explicit ROWS frame is more efficientSUM(amount) OVER ( PARTITION BY cust ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
/*ROWS vs RANGE:- ROWS: Counts physical rows (faster)- RANGE: Groups by value (requires value comparison)
For running totals, ROWS is usually sufficient and faster*/Pushing Filters Before Windows
-- SLOW: Filter after windowSELECT * FROM ( SELECT id, customer_id, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) as rn FROM orders) tWHERE rn = 1;
/*Problem: Computes ROW_NUMBER for ALL rows, then filters*/-- FASTER: Filter before window when possibleWITH recent_orders AS ( SELECT * FROM orders WHERE order_date > '2025-01-01' -- Filter first!)SELECT * FROM ( SELECT id, customer_id, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) as rn FROM recent_orders) tWHERE rn = 1;
/*Pre-filter reduces rows before expensive window computation*/Advanced Configuration
Memory Configuration
-- Work memory for sorts and hashesSET work_mem = '256MB';
-- Check if operations spill to diskEXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders ORDER BY customer_id;
/*Sort (actual time=500..750 ms) Sort Method: external merge Disk: 50000kB <-- Spilled!
Increase work_mem:*/SET work_mem = '512MB';
/*Sort (actual time=100..200 ms) Sort Method: quicksort Memory: 256000kB <-- In memory!*/Cost Model Tuning
-- For SSD storage (random reads are fast)SET random_page_cost = 1.1; -- Default is 4.0SET seq_page_cost = 1.0;
-- For in-memory databasesSET random_page_cost = 0.1;SET seq_page_cost = 0.1;
-- Effect: Optimizer will prefer index scans moreParallelism Configuration
-- Global parallel workersSET max_parallel_workers = 8;
-- Per-query parallel workersSET max_parallel_workers_per_gather = 4;
-- Table size threshold for parallelismSET min_parallel_table_scan_size = '8MB';SET min_parallel_index_scan_size = '512KB';
-- Cost threshold for parallelismSET parallel_setup_cost = 1000;SET parallel_tuple_cost = 0.1;Optimizer Feature Toggles
-- Disable specific features for testing/debuggingSET enable_hashjoin = false;SET enable_mergejoin = false;SET enable_nestloop = true; -- Force nested loop
-- Disable parallel executionSET max_parallel_workers_per_gather = 0;
-- Disable partition pruningSET enable_partition_pruning = false;
-- Disable JIT compilationSET jit = false;Best Practices Summary
Statistics
- Run ANALYZE after bulk loads
- Create extended statistics for correlated columns
- Monitor statistics freshness
- Configure autovacuum appropriately
Indexing
- Create indexes for JOIN and WHERE columns
- Use composite indexes for multi-column predicates
- Consider covering indexes for frequently accessed queries
- Monitor and remove unused indexes
Query Design
- Prefer JOINs over correlated subqueries
- Use NOT MATERIALIZED CTEs for single-use
- Match window function indexes to partition/order
- Filter early, aggregate late
Configuration
- Size work_mem for your largest sorts/hashes
- Adjust random_page_cost for your storage type
- Configure parallelism for your CPU count
- Use hints sparingly, only when optimizer errs
Related Documentation
Query Optimizer User Guide - Master every optimization technique.