SQL • Performance and Optimization

EXPLAIN/QUERY PLAN — Use EXPLAIN/QUERY PLAN conceptually to reason about performance (intro)

Learn to read execution plans so you can diagnose and fix slow queries before they reach production.

SQL illustration with a database, structured table, and query code symbols
Inside this lesson 10 sections 5 questions 8 figures

SECTION 1

Historical Context & Motivation

Relational databases emerged in the 1970s with a bold promise: users would declare what data they wanted, and the system would figure out how to retrieve it efficiently. This separation of logical intent from physical execution was elegant, but it created a new challenge: when a query ran slowly, developers had no visibility into the strategy the database engine had chosen. The query optimizer was a black box, and performance tuning often devolved into guesswork—adding indexes randomly, restructuring joins without evidence, or throwing hardware at the problem. The need for transparency gave rise to EXPLAIN and QUERY PLAN commands, which expose the optimizer's chosen execution strategy before (or after) a query runs.

1970 • Codd's Relational Model

Edgar F. Codd publishes his seminal paper introducing the relational model, separating logical data representation from physical storage. The concept of a declarative query language is born, but so is the problem of query optimization.

1979 • System R & Cost-Based Optimization

IBM's System R project develops the first cost-based query optimizer, which evaluates multiple execution plans and selects the cheapest one based on estimated I/O and CPU costs. The internal plan representation lays the groundwork for future EXPLAIN tools.

1988 • PostgreSQL's Predecessor & EXPLAIN

The POSTGRES project at UC Berkeley, precursor to PostgreSQL, begins exposing internal query plans to users. Early forms of EXPLAIN output allow researchers to inspect join orders, scan methods, and cost estimates directly.

2003 • EXPLAIN ANALYZE in PostgreSQL

PostgreSQL introduces EXPLAIN ANALYZE, which runs the query and reports actual row counts and execution times alongside the optimizer's estimates. This closes the feedback loop, enabling developers to see where estimates diverge from reality.

2010s • Visual & Vendor-Specific Plan Tools

Modern databases—MySQL, SQL Server, Oracle, SQLite—all offer their own EXPLAIN variants. GUI tools such as pgAdmin, MySQL Workbench, and SQL Server Management Studio render plans as interactive tree diagrams, making plan analysis more accessible than ever.

The central question that EXPLAIN addresses is deceptively simple: given a SQL statement, what physical operations will the database engine perform, in what order, and at what estimated cost? Answering this question is the first step toward principled performance optimization, moving from guesswork to evidence-based tuning.

SECTION 2

Core Principles & Definitions

Before diving into plan output, it is essential to understand the conceptual framework that underpins every execution plan. A SQL query goes through several stages inside the database engine: parsing, semantic analysis, optimization, and finally execution. The optimizer's job is to transform a declarative SQL statement into a physical execution plan—a tree of operators that specifies exactly which algorithms to use for scanning tables, joining results, sorting rows, and aggregating data. EXPLAIN intercepts this process and presents the chosen plan to the user without (in its basic form) actually running the query.

1

Execution Plan as a Tree

Every execution plan is a tree of operators. Leaf nodes access base data (table scans, index lookups), and internal nodes combine, filter, or transform results. Data flows upward from leaves to the root, which produces the final result set.
2

Cost Estimation

The optimizer assigns a cost to each plan node, typically in abstract units that blend estimated I/O operations and CPU cycles. The total cost is the sum across all nodes, and the optimizer picks the plan with the lowest total estimated cost.
3

Row Cardinality Estimates

At each node the optimizer predicts the number of rows (cardinality) that will flow through. These estimates drive join-order decisions and algorithm selection. Inaccurate estimates are the single most common cause of poor plans.
4

Access Methods

Leaf operators choose an access method: a sequential (full table) scan reads every row, an index scan navigates a B-tree to locate matching rows, and an index-only scan satisfies the query entirely from the index without touching the heap.
5

Join Algorithms

When two relations must be combined, the optimizer selects a join algorithm: nested loop join (good for small inner tables or indexed lookups), hash join (efficient for large unsorted inputs), or merge join (fast when both inputs are pre-sorted on the join key).
✦

KEY TAKEAWAY

Think of an execution plan like a GPS navigation route. Your SQL query is the destination; the optimizer is the routing engine that evaluates traffic (data distribution), road types (indexes), and distance (table sizes) to propose the fastest path. EXPLAIN lets you see the proposed route before you start driving, so you can spot a detour through a congested neighborhood (a full table scan on a million-row table) and reroute accordingly.

SECTION 3

Visual Explanation: Anatomy of an Execution Plan

The following diagram illustrates a typical execution plan tree for a query that joins two tables, applies a filter, and sorts the result. Each node represents a physical operator, with data flowing upward from the leaf-level scans to the root node that delivers the final output.

Execution Plan Tree — SELECT ... FROM orders JOIN customers WHERE ... ORDER BY ...Sortcost=45.2..52.1 rows=120Filtercost=32.0..45.2 rows=120Hash Join (orders.cust_id = customers.id)cost=12.0..32.0 rows=500Seq Scan on orderscost=0.0..8.5 rows=1000Index Scan on customerscost=0.3..3.5 rows=200LegendSort operatorFilter operatorJoin operatorSeq ScanIndex Scan↑ Data flows upward from leaf scans to the root Sort node
The plan tree for a join query. The two leaf nodes—Seq Scan on orders and Index Scan on customers—feed into a Hash Join. The result is filtered, then sorted before delivery. Each node displays estimated cost and row count.

Several details in this diagram deserve attention. First, notice that the sequential scan on the orders table estimates 1,000 rows—the entire table—while the index scan on customers estimates only 200, because it can leverage a B-tree index to skip irrelevant rows. Second, the cost values are cumulative: the Hash Join's upper cost of 32.0 includes the cost of both child scans. Third, the Filter node reduces the row count from 500 to 120, reflecting a selective WHERE condition that eliminates roughly 76% of joined rows. Understanding these relationships—parent costs include child costs, and each node may change the cardinality—is the foundation of reading any plan.

SECTION 4

How the Query Optimizer Chooses a Plan

The optimizer's decision process can be modeled as a search over a space of possible execution plans. For a query involving n tables, the number of possible join orderings grows factorially, and each join can use one of several algorithms, making the total plan space combinatorially explosive. Modern optimizers use dynamic programming to prune this space efficiently, building optimal sub-plans bottom-up and reusing them when constructing larger plans.

JOIN ORDER SEARCH SPACE

P(n)=n!×2n−1P(n) = n! × 2ⁿ⁻¹

Where n is the number of tables, n! counts the permutations of join order, and 2ⁿ⁻¹ accounts for choosing left-deep vs. bushy tree shapes. For 5 tables, this is 120 × 16 = 1,920 candidate plans.

The optimizer assigns a cost to each candidate using a cost model that combines I/O cost (pages read from disk) and CPU cost (comparisons, hashing, sorting). A simplified cost formula for a sequential scan illustrates the idea.

SEQUENTIAL SCAN COST

Cseq=Npages×seqpagecost+Nrows×cputuplecostC_seq = N_pages × seq_page_cost + N_rows × cpu_tuple_cost

Where N_pages is the number of disk pages in the table, seq_page_cost is the cost of reading one page sequentially (default 1.0 in PostgreSQL), N_rows is the total number of tuples, and cpu_tuple_cost is the per-tuple CPU processing cost (default 0.01).

INDEX SCAN COST

Cidx=Nindexpages×randompagecost+Nmatching×(cpuindextuplecost+cputuplecost)C_idx = N_index_pages × random_page_cost + N_matching × (cpu_index_tuple_cost + cpu_tuple_cost)

Index scans incur random I/O (default random_page_cost = 4.0), which is four times more expensive than sequential reads. However, N_matching may be far smaller than N_rows, making the index scan cheaper overall when selectivity is high.

⚠

Why Selectivity Matters

The selectivity of a predicate determines what fraction of rows survive a filter. A selectivity of 0.01 means only 1% of rows match. The optimizer uses column statistics—histograms, most-common-value lists, and distinct-value counts collected by ANALYZE—to estimate selectivity. When these statistics are stale, the optimizer may grossly misestimate cardinality, leading to catastrophically slow plans. Always keep your statistics up to date.

SECTION 5

Key Operator Types in Execution Plans

While every database engine has its own naming conventions, the fundamental operator types are remarkably consistent across systems. Understanding these operators and when the optimizer prefers one over another is the core skill of plan analysis. The following diagram classifies the most common operators into three categories: scan operators that access base data, join operators that combine two inputs, and auxiliary operators that sort, aggregate, or limit results.

Common Execution Plan OperatorsSCAN OPERATORSSeq ScanFull table read, O(N)Index ScanB-tree traversal, O(log N + k)Index Only ScanNo heap access neededBitmap ScanBatched index + heapJOIN OPERATORSNested LoopO(N × M), good wheninner is small/indexedHash JoinO(N + M), builds hashtable on smaller inputMerge JoinO(N + M), requiressorted inputsAUXILIARY OPERATORSSortO(N log N), may spill to diskHashAggregateGROUP BY via hashingLimitStops after N rowsMaterializeCaches sub-plan result
Operators are grouped into three families. Scan operators access table data with varying efficiency. Join operators combine two inputs using different algorithms. Auxiliary operators handle sorting, aggregation, and limiting.
OperatorBest WhenWatch Out For
Seq ScanReading most or all rows; no useful index exists; table is very small (fits in a few pages)On large tables with selective predicates—signals a missing index
Index ScanSelectivity is high (< 10–15% of rows match); results need to be sorted by index orderOn low-selectivity queries—random I/O overhead may exceed a sequential scan
Nested LoopInner relation is small or accessed via index; outer relation has few rows after filteringWhen both relations are large—quadratic row comparisons cause dramatic slowdowns
Hash JoinJoining large unsorted tables on equality predicates; sufficient work_mem for the hash tableWhen the build input is too large to fit in memory—causes multi-batch spills to disk
Merge JoinBoth inputs are already sorted on the join key (e.g., via index order); many-to-many joinsWhen a pre-sort is required—the cost of sorting may negate the merge benefit

When each major operator shines and when it signals a problem

SECTION 6

Worked Example: Reading and Interpreting an EXPLAIN Output

Consider the following PostgreSQL query and its EXPLAIN output. We want to find all orders placed in 2024 by customers in New York, sorted by order date. The schema has an orders table (500,000 rows) and a customers table (50,000 rows). There is a B-tree index on orders(order_date) and on customers(state).

ℹ

The Query

EXPLAIN SELECT o.order_id, o.total, c.name FROM orders o JOIN customers c ON o.customer_id = c.id WHERE c.state = 'NY' AND o.order_date >= '2024-01-01' AND o.order_date < '2025-01-01' ORDER BY o.order_date;

Interpreting the Execution Plan

  1. 1

    Step 1 — Identify the Root Node

    The outermost node in the EXPLAIN output is Sort with Sort Key: o.order_date. This tells us the engine will sort the final result set by order_date. The estimated cost range is cost=1245.32..1258.67, meaning the startup cost (time before the first row is available) is 1245.32 units and the total cost to produce all rows is 1258.67 units.
    Root: Sort on order_date, total cost ≈ 1258.67
  2. 2

    Step 2 — Examine the Join Node

    Below the Sort is a Hash Join on o.customer_id = c.id with cost=12.50..1245.32 rows=2500. The optimizer expects 2,500 rows to survive the join. It chose a hash join because neither input is pre-sorted on the join key and both are moderately large.
    Join: Hash Join, estimated 2,500 rows
  3. 3

    Step 3 — Inspect the Build (Inner) Input

    The hash table is built from an Index Scan on customers using the index on state with the condition state = 'NY'. Estimated rows: 5,000 (10% of 50,000). This is efficient because only the matching rows are loaded into the hash table, keeping memory usage low.
    Build input: 5,000 NY customers via Index Scan
  4. 4

    Step 4 — Inspect the Probe (Outer) Input

    The probe side is an Index Scan on orders using the order_date index with the range condition >= '2024-01-01' AND < '2025-01-01'. Estimated rows: 50,000 (10% of 500,000). Each of these 50,000 rows is probed against the hash table to find matching customer IDs.
    Probe input: 50,000 orders in 2024 via Index Scan
  5. 5

    Step 5 — Assess the Plan & Identify Optimization Opportunities

    The plan is reasonable: both leaf nodes use indexes, and the hash join is appropriate for this cardinality. However, notice the Sort node at the top. Since the orders are already accessed via an index on order_date, the rows from the probe side arrive in sorted order. If the optimizer recognized this, it might use a Merge Join instead of Hash Join + Sort, potentially eliminating the explicit sort altogether. This is a case where understanding the plan can suggest restructuring (e.g., adding a composite index or adjusting join strategies via planner hints).
    Potential improvement: eliminate the Sort node by leveraging index ordering via a Merge Join

SECTION 7

EXPLAIN Variants Across Database Systems

While the conceptual framework is universal, each database system offers a different syntax and varying levels of detail in its EXPLAIN output. Understanding these differences is practical knowledge for anyone working in heterogeneous environments.

FeaturePostgreSQLMySQLSQLite
Basic SyntaxEXPLAIN SELECT ...EXPLAIN SELECT ...EXPLAIN QUERY PLAN SELECT ...
With Actual ExecutionEXPLAIN ANALYZEEXPLAIN ANALYZE (MySQL 8.0.18+)Not available (lightweight engine)
Output FormatsTEXT, JSON, XML, YAMLTabular, JSON, TREETabular (id, parent, detail)
Cost ModelDetailed (startup..total cost, rows, width)Rows estimated; cost in TREE formatNo cost numbers; describes strategy only
Buffer / I/O InfoEXPLAIN (ANALYZE, BUFFERS)Performance schema / slow query logNot available

EXPLAIN feature comparison across three popular database engines

✦

KEY TAKEAWAY

Regardless of which database you use, the fundamental mental model is the same: you have a tree of operators, data flows from leaves to root, and each node has an estimated cost and cardinality. The syntax and level of detail vary, but the reasoning process is portable. Once you can read a PostgreSQL plan, translating to MySQL or SQLite is a matter of learning the local dialect, not a fundamentally different skill.

SECTION 8

Connection to Advanced Performance Tuning

The introductory EXPLAIN skills covered in this lesson form the foundation for more advanced optimization techniques. As you progress, you will encounter scenarios where basic plan reading is insufficient and where deeper tools and strategies become essential. The table below maps the concepts introduced here to their advanced counterparts.

Introductory ConceptAdvanced Extension
Reading estimated costs and row countsUsing EXPLAIN ANALYZE to compare estimates vs. actuals; identifying cardinality estimation errors as root causes of slow queries
Recognizing scan types (Seq Scan vs. Index Scan)Designing composite indexes, partial indexes, and covering indexes to eliminate heap access entirely (index-only scans)
Understanding join algorithm selectionTuning work_mem and hash bucket counts; forcing join order with query hints or CTE materialization; understanding parallel hash joins
Observing Sort operators in plansDetecting disk-spill sorts via EXPLAIN (ANALYZE, BUFFERS); designing indexes that provide pre-sorted output to eliminate explicit sorts
Conceptual cost model awarenessAdjusting planner cost constants (random_page_cost, effective_cache_size); using pg_stat_statements for workload-level analysis; auto-tuning with machine learning–based advisors

From introductory EXPLAIN skills to advanced optimization techniques

A particularly important advanced topic is plan stability. In production systems, a query that performs well today might suddenly degrade if the optimizer chooses a different plan after a statistics refresh or a data distribution shift. Techniques such as plan pinning (SQL Server), SQL plan baselines (Oracle), and pg_hint_plan (PostgreSQL) allow engineers to constrain the optimizer's choices when stability is more valuable than theoretical optimality. Understanding EXPLAIN output is a prerequisite for all of these advanced strategies, because you must first identify the good plan before you can preserve it.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

A query execution plan shows a Seq Scan on a table with 10 million rows, even though the WHERE clause filters on a column with a B-tree index. The predicate matches approximately 60% of the rows. Explain conceptually why the optimizer might prefer a sequential scan over an index scan in this situation.
Show answer
When a predicate has low selectivity (here 60% of rows match), an index scan would require traversing the B-tree and then performing a random I/O fetch for each matching row's heap page. With 6 million random page reads, the I/O cost far exceeds the cost of a single sequential pass through all 10 million rows. Sequential reads are typically 4× cheaper per page than random reads (reflected in PostgreSQL's default random_page_cost = 4.0 vs. seq_page_cost = 1.0). The optimizer's cost model correctly determines that the sequential scan is faster for this low-selectivity predicate, even though an index exists. The index would only be advantageous if the selectivity were much higher—typically below 10–15% of rows.

Question 2: BASIC CALCULATION

A table has 1,000 pages and 100,000 rows. Using PostgreSQL's default cost parameters (seq_page_cost = 1.0, cpu_tuple_cost = 0.01), calculate the estimated cost of a sequential scan on this table.
Show answer
Applying the sequential scan cost formula: C_seq = N_pages × seq_page_cost + N_rows × cpu_tuple_cost = 1,000 × 1.0 + 100,000 × 0.01 = 1,000 + 1,000 = 2,000 cost units. The I/O component (reading 1,000 pages) and the CPU component (processing 100,000 tuples) contribute equally in this case. In practice, for tables with many narrow rows per page, the CPU component can dominate; for wide rows with few per page, I/O dominates. This formula gives you a baseline to compare against index scan costs for the same table.

Question 3: INTERMEDIATE

Consider the following abbreviated EXPLAIN output for a PostgreSQL query: Sort (cost=890.12..895.37 rows=2100) -> Hash Join (cost=45.00..870.25 rows=2100) Hash Cond: (o.cust_id = c.id) -> Seq Scan on orders o (cost=0.00..780.00 rows=40000) -> Hash (cost=35.00..35.00 rows=800) -> Seq Scan on customers c (cost=0.00..35.00 rows=800) Filter: (region = 'West') The orders table has 40,000 rows and an index on cust_id. Why might the optimizer have chosen a Seq Scan on orders instead of using the cust_id index? Suggest one change that could improve this plan.
Show answer
The optimizer chose a Seq Scan on orders because there is no filter predicate on the orders table—it needs all 40,000 rows. The cust_id index could theoretically be used for the join, but a Hash Join with a full scan is more efficient here: it reads orders sequentially (cheap I/O) and probes a small hash table built from 800 customer rows. Using the cust_id index for a Nested Loop would mean 800 index lookups into orders (one per customer), each involving random I/O, which is likely more expensive than a single sequential pass. To improve this plan, consider adding an index on customers(region) if one does not exist—this would reduce the 35.00-cost Seq Scan on customers to a cheaper Index Scan. Additionally, if the orders table has a date range or other selective filter that is missing from this query, adding one would reduce the probe side cardinality dramatically.

Question 4: APPLIED

You are developing a web application dashboard that queries a 50-million-row events table. The query filters on user_id and event_type, and orders results by created_at DESC LIMIT 20. The EXPLAIN output shows an Index Scan on a single-column index on user_id (estimated 500,000 rows), followed by a Filter on event_type (estimated 5,000 rows surviving), followed by a Sort and Limit. The query takes 3 seconds. Propose a specific index design change and explain how it would transform the execution plan.
Show answer
The current plan scans 500,000 rows via the user_id index, then discards 99% of them in a Filter step, then sorts the surviving 5,000 rows to apply the LIMIT 20. This is highly inefficient. The solution is to create a composite index on (user_id, event_type, created_at DESC). With this index, the optimizer can perform an Index Scan that navigates directly to the specific (user_id, event_type) combination, and the rows arrive pre-sorted by created_at DESC due to the index ordering. The Limit operator can then stop after reading just 20 rows from the index, without ever scanning 500,000 rows or performing an explicit sort. This transforms the plan from 'scan 500K → filter to 5K → sort → limit 20' into 'index scan → limit 20,' reducing the work from O(500,000) to O(20) in the best case. If the index includes all selected columns, it may even become an Index Only Scan, eliminating heap fetches entirely.

Question 5: CRITICAL THINKING

EXPLAIN shows estimated costs but not actual execution metrics (unless ANALYZE is used). Discuss at least three scenarios in which the optimizer's estimated plan cost could be significantly misleading—where a plan with a lower estimated cost actually performs worse than an alternative with a higher estimated cost. For each scenario, explain the root cause and how you would detect it.
Show answer
First, stale or missing statistics: if ANALYZE has not been run recently, the optimizer's cardinality estimates may be wildly inaccurate. For example, after a bulk insert that doubles a table's size, the optimizer might still estimate 100,000 rows instead of 200,000, choosing a Nested Loop when a Hash Join would be faster. Detection: run EXPLAIN ANALYZE and compare estimated rows to actual rows; large discrepancies indicate stale stats. Second, correlated predicates: the optimizer typically assumes independence between WHERE conditions. If a query filters on both city='Chicago' AND state='IL', the optimizer multiplies the individual selectivities (e.g., 0.02 × 0.02 = 0.0004), greatly underestimating the true selectivity (which is closer to 0.02, since Chicago implies Illinois). This leads to an overly optimistic cardinality estimate that may cause the optimizer to choose an index scan when a sequential scan would be faster. Detection: again, compare estimated vs. actual rows in EXPLAIN ANALYZE; consider creating extended statistics (CREATE STATISTICS in PostgreSQL) for correlated columns. Third, caching effects and I/O assumptions: the cost model assumes fixed costs for random and sequential page reads, but in reality, frequently accessed pages reside in the buffer cache, making 'random' reads nearly free. A plan that appears expensive due to many random reads might actually execute quickly because the working set fits in memory. Conversely, a plan estimated as cheap might cause cache thrashing if it reads large tables that evict useful pages. Detection: use EXPLAIN (ANALYZE, BUFFERS) to see buffer hit rates, and compare shared_hits vs. shared_reads to assess actual I/O patterns.

SUMMARY

Lesson Summary

Summary

The EXPLAIN and QUERY PLAN commands expose the database optimizer's chosen execution plan—a tree of operators where data flows from leaf-level scan nodes upward through join and auxiliary operators to the root. Each node carries an estimated cost and cardinality derived from the optimizer's cost model and column statistics. By reading these plans, you can identify inefficiencies—such as full table scans where index scans are warranted, or unnecessary sort operations—and make evidence-based decisions about index design, query restructuring, and configuration tuning.

Key skills developed in this lesson include recognizing scan operators (Seq Scan, Index Scan, Index Only Scan), join algorithms (Nested Loop, Hash Join, Merge Join), and auxiliary operators (Sort, Aggregate, Limit). Understanding when each is appropriate—and recognizing when the optimizer's choice signals a problem—is the essential first step toward principled SQL performance optimization. As you advance, these skills extend naturally to EXPLAIN ANALYZE for runtime validation, composite index design, and workload-level performance analysis.

Varsity Tutors • SQL • EXPLAIN/QUERY PLAN — Use EXPLAIN/QUERY PLAN conceptually to reason about performance (intro)