Skip to content

Query Optimization | Databases

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)

PostgreSQL collects per-column statistics during ANALYZE:

SELECT attname, null_frac, n_distinct, avg_width, correlation,
most_common_vals, most_common_freqs,
histogram_bounds
FROM pg_stats
WHERE tablename = "orders'
ORDER BY attname;
StatisticMeaning
null_fracFraction of rows with NULL in this column
n_distinctPositive: approximate distinct values. Negative: fraction of rows that are distinct
avg_widthAverage byte width of column values
correlationPhysical vs logical order correlation (-1.0 to 1.0)
most_common_valsMost frequent values (MCV list)
most_common_freqsFrequencies of MCV values
histogram_boundsBoundaries for histogram of non-MCV values

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 distribution
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
-- Global default
SET default_statistics_target = 200;

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/Os
SELECT attname, correlation FROM pg_stats WHERE tablename = 'orders';

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 pairs
CREATE 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 ANALYZE
ANALYZE orders;
-- Verify extended statistics are used
SELECT * FROM pg_stats_ext WHERE tablename = 'orders';
Statistic TypeCapturesUse Case
ndistinctDistinct count of column combinationsGROUP BY multiple columns
dependenciesFunctional dependenciesWHERE a = 1 AND b = 2 (b depends on a)
mcvMost common value combinationsMulti-column filter selectivity

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 row
Best 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)

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 rows
Best when: both sides are large, no useful index, equijoin
Memory: 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 disk
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.customer_id;
-- Look for "Batches" > 1 in Hash Join node output

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 merge
Best 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)
ConditionPreferred Strategy
One table is very small (< 1000 rows)Nested Loop
Inner table has a selective index on join keyNested Loop
Both tables large, equijoin, sufficient work_memHash Join
Both sides sorted on join keyMerge Join
Non-equijoin (e.g., range condition)Nested Loop
Inner table large, no index, insufficient work_memMerge Join (after sort)
-- Correlated subquery: executed once per outer row (slow)
SELECT * FROM orders o
WHERE 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 o
WHERE 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.

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 optimizer
SELECT * 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);

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 times
WITH 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;

PostgreSQL 9.6+ supports parallel execution for sequential scans, joins, and aggregates.

-- Maximum number of parallel workers per query
SET max_parallel_workers_per_gather = 4;
-- Maximum number of parallel workers across all queries
SET max_parallel_workers = 8;
-- Minimum table size (in 8KB pages) to consider parallel scan
SET min_parallel_table_scan_size = '8MB';
-- Minimum index size to consider parallel index scan
SET min_parallel_index_scan_size = '512kB';
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)
-- 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)”
ScenarioParallel Helps?Reason
Full table scan on large tableYesWork distributed across workers
Aggregates on large tablesYesPartial aggregates combined at coordinator
Index scan with few rowsNoCoordination overhead exceeds scan cost
Foreign data wrapper queriesNoFDW does not support parallel execution
Queries returning few rowsNoGather overhead exceeds benefit