Query Optimization | Databases
Query Optimizer Architecture
Section titled “Query Optimizer Architecture”Rule-Based vs Cost-Based Optimization
Section titled “Rule-Based vs Cost-Based Optimization”Rule-Based Optimizer (RBO): Uses a fixed set of heuristics to transform queries. Access paths Are chosen based on rules like “use an index if available” and “avoid full table scans.” RBO does Not consider data distribution, row counts, or I/O costs. Oracle deprecated RBO in Oracle 10g.
Cost-Based Optimizer (CBO): Estimates the cost of alternative execution plans using statistics About the data (row counts, column distributions, index sizes) and system parameters (CPU speed, Disk I/O cost). PostgreSQL, MySQL, and modern Oracle use CBO exclusively.
flowchart TD A[SQL Query] --> B[Parser] B --> C[Query Tree / AST] C --> D[Query Rewriter] D --> E[Optimizer] E --> F1[Plan 1: Seq Scan + Nested Loop] E --> F2[Plan 2: Index Scan + Hash Join] E --> F3[Plan 3: Bitmap Scan + Merge Join] F1 --> G[Cost Estimator] F2 --> G F3 --> G G --> H[Select Lowest-Cost Plan] H --> I[Executor]The PostgreSQL optimizer uses a dynamic programming approach: it explores join orderings and Access paths, estimating costs based on:
seq_page_cost: cost of a sequential disk page fetch (default 1.0)random_page_cost: cost of a random disk page fetch (default 4.0)cpu_tuple_cost: cost of processing each tuple (default 0.01)cpu_index_tuple_cost: cost of processing each index entry (default 0.005)cpu_operator_cost: cost of processing each operator (default 0.0025)
Statistics
Section titled “Statistics”Column Statistics
Section titled “Column Statistics”PostgreSQL collects per-column statistics during ANALYZE:
SELECT attname, null_frac, n_distinct, avg_width, correlation, most_common_vals, most_common_freqs, histogram_boundsFROM pg_statsWHERE tablename = "orders'ORDER BY attname;| Statistic | Meaning |
|---|---|
null_frac | Fraction of rows with NULL in this column |
n_distinct | Positive: approximate distinct values. Negative: fraction of rows that are distinct |
avg_width | Average byte width of column values |
correlation | Physical vs logical order correlation (-1.0 to 1.0) |
most_common_vals | Most frequent values (MCV list) |
most_common_freqs | Frequencies of MCV values |
histogram_bounds | Boundaries for histogram of non-MCV values |
Histograms
Section titled “Histograms”PostgreSQL stores an equi-depth histogram with default_statistics_target buckets (default 100). Each bucket contains approximately the same number of rows. The histogram is used to estimate Selectivity for conditions on non-MCV values.
-- Increase statistics target for a column with skewed distributionALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;ANALYZE orders;
-- Global defaultSET default_statistics_target = 200;Correlation
Section titled “Correlation”The correlation statistic measures how closely the physical row order on disk matches the logical Order of the column value. A correlation of 1.0 means the data is perfectly sorted by this column on Disk. This matters for index scans:
-- High correlation (e.g., 0.99): index scan is efficient-- Low correlation (e.g., 0.01): index scan requires many random I/OsSELECT attname, correlation FROM pg_stats WHERE tablename = 'orders';Extended Statistics (PostgreSQL 10+)
Section titled “Extended Statistics (PostgreSQL 10+)”When multiple columns are used in a WHERE clause, independent column statistics can lead to poor Estimates. Extended statistics capture cross-column correlations:
-- Create extended statistics for column pairsCREATE STATISTICS s_orders_region_date (ndistinct, dependencies, mcv) ON region, order_date FROM orders;
-- Dependencies: functional dependency (region determines country)CREATE STATISTICS s_orders_region_country (dependencies) ON region, country FROM orders;
-- After creating extended statistics, run ANALYZEANALYZE orders;
-- Verify extended statistics are usedSELECT * FROM pg_stats_ext WHERE tablename = 'orders';| Statistic Type | Captures | Use Case |
|---|---|---|
ndistinct | Distinct count of column combinations | GROUP BY multiple columns |
dependencies | Functional dependencies | WHERE a = 1 AND b = 2 (b depends on a) |
mcv | Most common value combinations | Multi-column filter selectivity |
Join Strategies
Section titled “Join Strategies”Nested Loop Join
Section titled “Nested Loop Join”For each row in the outer (driving) table, scan the inner table for matching rows.
Cost: O(N * M) where N = outer rows, M = inner rows per outer rowBest when: outer is small, inner has a useful index, or one side is very small-- EXPLAIN shows:-- -> Nested Loop (cost=0.43..12.50 rows=10)-- -> Seq Scan on small_table (cost=0.00..1.50 rows=10)-- -> Index Scan using idx_large_id on large_table (cost=0.43..1.00 rows=1)Hash Join
Section titled “Hash Join”Build an in-memory hash table from the inner (build) side, then probe with the outer side.
Cost: O(N + M) where N = outer rows, M = inner rowsBest when: both sides are large, no useful index, equijoinMemory: requires work_mem for the hash table-- EXPLAIN shows:-- -> Hash Join (cost=450.00..850.00 rows=10000)-- Hash Cond: (a.customer_id = b.customer_id)-- -> Seq Scan on orders a (cost=0.00..300.00 rows=10000)-- -> Hash (cost=200.00..200.00 rows=5000)-- -> Seq Scan on customers b (cost=0.00..200.00 rows=5000)If the hash table exceeds work_memPostgreSQL spills to disk, creating multiple batches. This Degrades performance significantly. Monitor with:
-- Check if hash joins spilled to diskEXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders o JOIN customers c ON o.customer_id = c.customer_id;-- Look for "Batches" > 1 in Hash Join node outputMerge Join
Section titled “Merge Join”Both inputs must be sorted on the join key. Walk through both sorted streams simultaneously.
Cost: O(N log N + M log M) for sorting, O(N + M) for mergeBest when: both sides already sorted (index order), or when a presorted merge is cheaper than hashing-- EXPLAIN shows:-- -> Merge Join (cost=1000.00..2000.00 rows=20000)-- Merge Cond: (a.id = b.id)-- -> Index Scan using idx_a_id on table_a (cost=0.42..800.00 rows=20000)-- -> Sort (cost=500.00..510.00 rows=10000)-- -> Seq Scan on table_b (cost=0.00..200.00 rows=10000)Join Strategy Selection Guide
Section titled “Join Strategy Selection Guide”| Condition | Preferred Strategy |
|---|---|
| One table is very small (< 1000 rows) | Nested Loop |
| Inner table has a selective index on join key | Nested Loop |
| Both tables large, equijoin, sufficient work_mem | Hash Join |
| Both sides sorted on join key | Merge Join |
| Non-equijoin (e.g., range condition) | Nested Loop |
| Inner table large, no index, insufficient work_mem | Merge Join (after sort) |
Subquery Optimization
Section titled “Subquery Optimization”Correlated vs Uncorrelated Subqueries
Section titled “Correlated vs Uncorrelated Subqueries”-- Correlated subquery: executed once per outer row (slow)SELECT * FROM orders oWHERE EXISTS ( SELECT 1 FROM customers c WHERE c.customer_id = o.customer_id AND c.tier = 'premium');
-- Uncorrelated subquery: executed once (fast)SELECT * FROM orders oWHERE o.customer_id IN ( SELECT customer_id FROM customers WHERE tier = 'premium');PostgreSQL may rewrite correlated subqueries as joins (subquery flattening or “pull-up”), but this Depends on the specific query shape. Use EXPLAIN to verify.
Semi-Join
Section titled “Semi-Join”A semi-join returns rows from the outer table where a match exists in the inner table, but does not Duplicate outer rows. EXISTS and IN are converted to semi-joins:
-- Both are converted to Hash Semi Join by the optimizerSELECT * FROM orders WHERE customer_id IN (SELECT id FROM premium_customers);SELECT * FROM orders WHERE EXISTS (SELECT 1 FROM premium_customers WHERE id = customer_id);Materialized Subqueries
Section titled “Materialized Subqueries”CTEs in PostgreSQL 12+ may be inlined or materialized. When materialized, the subquery is executed Once and stored:
-- The CTE may be materialized if referenced multiple timesWITH active_users AS ( SELECT user_id, COUNT(*) AS login_count FROM login_events WHERE last_login >= CURRENT_DATE - INTERVAL '30 days' GROUP BY user_id)SELECT (SELECT COUNT(*) FROM active_users) AS total_active, (SELECT COUNT(*) FROM active_users WHERE login_count > 10) AS power_users, (SELECT AVG(login_count) FROM active_users) AS avg_logins;Parallel Query
Section titled “Parallel Query”PostgreSQL 9.6+ supports parallel execution for sequential scans, joins, and aggregates.
Configuration Parameters
Section titled “Configuration Parameters”-- Maximum number of parallel workers per querySET max_parallel_workers_per_gather = 4;
-- Maximum number of parallel workers across all queriesSET max_parallel_workers = 8;
-- Minimum table size (in 8KB pages) to consider parallel scanSET min_parallel_table_scan_size = '8MB';
-- Minimum index size to consider parallel index scanSET min_parallel_index_scan_size = '512kB';Parallel Seq Scan
Section titled “Parallel Seq Scan”EXPLAIN (ANALYZE)SELECT COUNT(*) FROM large_table;
-- -> Gather (cost=0.00..12345.67 rows=1 width=8)-- Workers Planned: 4-- Workers Launched: 4-- -> Parallel Seq Scan on large_table (cost=0.00..10000.00 rows=2500000)Parallel Join
Section titled “Parallel Join”-- Hash Join can be parallelized (each worker builds a partial hash table)-- Nested Loop Join can be parallelized (each worker handles a subset of outer rows)-- Merge Join can be parallelized in PostgreSQL 13+ (requires sorted inputs)EXPLAIN (ANALYZE)SELECT * FROM large_a JOIN large_b ON a.id = b.id;When Parallel Query Helps (and When It Does Not)
Section titled “When Parallel Query Helps (and When It Does Not)”| Scenario | Parallel Helps? | Reason |
|---|---|---|
| Full table scan on large table | Yes | Work distributed across workers |
| Aggregates on large tables | Yes | Partial aggregates combined at coordinator |
| Index scan with few rows | No | Coordination overhead exceeds scan cost |
| Foreign data wrapper queries | No | FDW does not support parallel execution |
| Queries returning few rows | No | Gather overhead exceeds benefit |