Explain Plan and Query Optimization
EXPLAIN PLAN shows you the strategy Oracle's optimiser chose to execute your query — which tables it accesses first, which indexes it uses, how it joins them, and how much work each step costs. Reading a plan is the single most useful tuning skill in Oracle.
This chapter goes deeper than the introduction in Performance — covering the full plan tree, every common operation, hints, AUTOTRACE, SQL Monitoring, and how to read plans like a tuning specialist.
Generating an Explain Plan
The classic two-step approach:
EXPLAIN PLAN FOR
SELECT * FROM employees WHERE department_id = 50;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
This stores the plan in the PLAN_TABLE table and then formats it for display. The output is the execution plan in tree form.
If you have AUTOTRACE enabled, you get the plan automatically after every query:
SET AUTOTRACE ON EXPLAIN
SELECT * FROM employees WHERE department_id = 50;
-- query runs, plan displayed
For more detail, use DBMS_XPLAN formatting options:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'ALL'));
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'ADVANCED'));
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'BASIC +PARTITION +PARALLEL'));
Reading a Plan
A plan output looks like this:
| Id | Operation | Name | Rows | Bytes | Cost |
| 0 | SELECT STATEMENT | | 45 | 3105 | 3 |
| 1 | NESTED LOOPS | | 45 | 3105 | 3 |
| 2 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 45 | 2880 | 2 |
|* 3 | INDEX RANGE SCAN | EMP_DEPT_IDX | 45 | | 1 |
| 4 | TABLE ACCESS BY INDEX ROWID| DEPARTMENTS | 1 | 5 | 1 |
|* 5 | INDEX UNIQUE SCAN | DEPT_PK | 1 | | 0 |
Read it like a tree, leaves first:
- Steps are numbered by
Id - Indentation shows nesting — deeper indent = inner operation
- Children execute before parents
- Steps with
*have predicates (filtering conditions) shown below the table Rowsis the optimiser's estimate of how many rows that step producesBytesis the estimated data volumeCostis a relative measure of work (CPU + I/O), not seconds
Execution order for the example above:
- Step 3 — Use the
EMP_DEPT_IDXindex to find employees in department 50 - Step 2 — Fetch the actual employee rows from the
EMPLOYEEStable - Step 5 — For each employee, look up the department by primary key
- Step 4 — Fetch the department row
- Step 1 — Nested loops produce the joined result
- Step 0 — Final result returned
Reading bottom-up at the same indentation level, then up to the parent, gives you the order operations actually happen.
Cardinality and Cost — What the Numbers Mean
Rows (cardinality) is the optimiser's estimate based on statistics. Two implications:
- The optimiser's choices are driven by these estimates. If they're wrong (e.g., stale statistics), the chosen plan can be terribly wrong.
- Estimates that differ wildly from reality are the #1 cause of bad plans. Check actual vs estimated rows.
Compare estimated to actual:
SELECT /*+ GATHER_PLAN_STATISTICS */ * FROM employees WHERE department_id = 50;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
This shows E-Rows (estimated) and A-Rows (actual) side by side. A 10× discrepancy is a red flag; 100× is critical.
| Id | Operation | E-Rows | A-Rows |
| 0 | SELECT STATEMENT | | 102 |
|* 1 | TABLE ACCESS FULL EMP | 50 | 102 | ← estimate was 2× off
When estimates are wrong:
- Stale statistics →
BEGIN DBMS_STATS.GATHER_TABLE_STATS(USER, 'EMPLOYEES'); END; - Skewed data with no histogram → gather statistics with
METHOD_OPT => 'FOR COLUMNS dept_id SIZE 254' - Bind variable peeking issues → consider adaptive cursors
- Correlated columns → use extended statistics
Common Operations You Need to Recognise
TABLE ACCESS FULL
Reads every block of a table.
TABLE ACCESS FULL | EMPLOYEES
Good when: small tables, or > ~5% of the table needs to be read. Bad when: huge table, narrow filter — usually means a missing index.
INDEX UNIQUE SCAN
Looks up a single row by a unique key.
INDEX UNIQUE SCAN | EMP_PK
The optimal access method. Used for primary key lookups and unique indexes with = predicates.
INDEX RANGE SCAN
Returns a range of values from a B-tree index.
INDEX RANGE SCAN | EMP_DEPT_IDX
Good for WHERE col BETWEEN ... AND ..., WHERE col = 'X' on non-unique indexes, WHERE col LIKE 'A%'.
INDEX FULL SCAN
Reads every entry of an index in order. Cheaper than a table scan because the index is smaller, plus the data comes back sorted.
INDEX FULL SCAN | EMP_NAME_IDX
Used for ORDER BY on an indexed column when most or all rows are needed.
INDEX FAST FULL SCAN
Reads every entry of an index in physical order (unordered), much faster than INDEX FULL SCAN. Used when order doesn't matter and the index covers all needed columns.
INDEX FAST FULL SCAN | EMP_DEPT_IDX
Effectively "the index is a smaller table I can scan instead".
INDEX SKIP SCAN
Used on composite indexes when the leading column isn't in the WHERE clause but a later column is.
INDEX SKIP SCAN | EMP_DEPT_JOB_IDX
Skips through the leading values, scanning sub-ranges for each. Useful when the leading column has low cardinality.
NESTED LOOPS
For each row in the outer set, look up matching rows in the inner.
NESTED LOOPS
TABLE ACCESS | EMP
INDEX SCAN | DEPT_PK
Best when: outer set is small AND inner has an index on the join key. Performance scales with outer × log(inner).
HASH JOIN
Build a hash table on one side, probe with the other.
HASH JOIN
TABLE ACCESS FULL | EMP
TABLE ACCESS FULL | DEPT
Best for joining two large sets. Cost is approximately (left size + right size), so it scales linearly with table sizes.
MERGE JOIN (SORT-MERGE)
Sort both sides, then walk through both in order matching them up.
MERGE JOIN
SORT JOIN
TABLE ACCESS FULL | EMP
SORT JOIN
TABLE ACCESS FULL | DEPT
Used when both sides are already sorted (e.g., from index access) or are too big for hash join's memory.
FILTER
A predicate applied after another operation, often a correlated subquery.
FILTER
TABLE ACCESS FULL | EMP
TABLE ACCESS FULL | DEPT (run once per emp row)
Subqueries that don't unnest become FILTERs — check for this if a subquery seems slow.
Other Operations to Recognise
| Operation | Meaning |
|---|---|
SORT ORDER BY |
Final sort for ORDER BY |
SORT GROUP BY |
Grouping that requires sorting |
HASH GROUP BY |
Grouping using a hash table (no sort needed) |
WINDOW SORT |
Sort to support a window function |
PARTITION RANGE ITERATOR |
Pruning multiple partitions |
PARTITION RANGE SINGLE |
Pruning to one partition |
BITMAP CONVERSION |
Combining bitmap indexes |
CONNECT BY WITH FILTERING |
Hierarchical query |
Hints — Suggesting a Plan
Hints are comments that tell the optimiser to prefer a particular operation. They take the form /*+ HINT_NAME(params) */ and go right after the SELECT, INSERT, UPDATE, or DELETE keyword.
SELECT /*+ INDEX(e emp_dept_idx) */ *
FROM employees e
WHERE department_id = 50;
Common hints:
| Hint | Effect |
|---|---|
/*+ INDEX(table index_name) */ |
Use this index |
/*+ NO_INDEX(table index_name) */ |
Don't use this index |
/*+ FULL(table) */ |
Full table scan |
/*+ USE_NL(table) */ |
Nested loops join |
/*+ USE_HASH(table) */ |
Hash join |
/*+ USE_MERGE(table) */ |
Sort-merge join |
/*+ LEADING(t1 t2 t3) */ |
Use this join order |
/*+ PARALLEL(table N) */ |
Use N parallel slaves |
/*+ APPEND */ |
Direct-path INSERT (bypass buffer cache) |
/*+ FIRST_ROWS(N) */ |
Optimise for fetching first N rows quickly |
/*+ ALL_ROWS */ |
Optimise for total throughput (default) |
/*+ MATERIALIZE */ |
Materialise a CTE instead of inlining it |
When to use hints:
- Production query is critical and you need a guaranteed plan
- Statistics are stale or temporarily unreliable
- Optimiser is making a wrong choice in a specific scenario
When NOT to use hints:
- "Just to be sure" — let the optimiser do its job
- To work around stale statistics — fix the statistics instead
- When you don't understand why the chosen plan is wrong
Hints lock you into a plan that may become wrong as data grows. Use sparingly.
AUTOTRACE
AUTOTRACE is a SQL*Plus / SQL Developer feature that shows the plan and runtime statistics for queries automatically.
SET AUTOTRACE ON -- query, plan, statistics
SET AUTOTRACE ON EXPLAIN -- plan only, no statistics
SET AUTOTRACE TRACEONLY -- statistics, suppress query output
SET AUTOTRACE OFF -- turn off
Statistics shown include:
consistent gets— logical reads (block accesses in cache)physical reads— disk readsrecursive calls— internal calls (parsing, dictionary lookups)sorts (memory)andsorts (disk)— sort operations
Logical reads are the primary tuning metric. Fewer logical reads = less work. Compare consistent gets between two query variants to find the better one.
SQL Monitoring
For long-running queries, SQL Monitoring shows actual progress in real time:
SELECT DBMS_SQLTUNE.REPORT_SQL_MONITOR(sql_id => 'abc123xyz')
FROM dual;
This requires the Tuning Pack license. It shows:
- Which operation is currently running
- How much CPU/IO time has been spent
- Estimated vs actual rows at each step
- Wait events
- Parallel execution servers (if any)
In Enterprise Manager / OEM Cloud Control, this view is graphical and live-updating — the gold standard for monitoring long queries.
Worked Example — Diagnosing a Slow Query
A report query is taking 8 minutes:
SELECT d.department_name, COUNT(*), SUM(e.salary)
FROM employees e
JOIN departments d ON e.department_id = d.department_id
WHERE e.hire_date BETWEEN DATE '2020-01-01' AND DATE '2024-12-31'
GROUP BY d.department_name;
Step 1 — Get the actual plan with rowsource statistics:
SELECT /*+ GATHER_PLAN_STATISTICS */ ...
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
| Id | Operation | Name | E-Rows | A-Rows | A-Time |
| 0 | SELECT STATEMENT | | | 27 | 8:12.45 |
| 1 | HASH GROUP BY | | 20 | 27 | 8:12.45 |
|* 2 | HASH JOIN | | 50,000 | 350,000| 8:11.30 |
| 3 | TABLE ACCESS FULL | DEPARTMENTS | 27 | 27 | 0:00.01 |
|* 4 | TABLE ACCESS FULL | EMPLOYEES | 50,000 | 350,000| 8:10.95 |
Step 2 — Spot the problems:
- Step 4:
E-Rows50,000 vsA-Rows350,000 — estimate is 7× low - Step 4: full table scan on EMPLOYEES taking 8 minutes
- No index on
hire_date
Step 3 — Apply the fix:
-- Add an index on hire_date:
CREATE INDEX emp_hire_date_idx ON employees(hire_date);
-- Refresh statistics so the optimiser knows about the new index:
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'EMPLOYEES',
method_opt => 'FOR COLUMNS hire_date SIZE 254');
END;
/
Step 4 — Verify:
| Id | Operation | A-Rows | A-Time |
| 0 | SELECT STATEMENT | 27 | 0:00.85 |
| 1 | HASH GROUP BY | 27 | 0:00.85 |
|* 2 | HASH JOIN | 350K | 0:00.80 |
| 3 | TABLE ACCESS FULL DEPARTMENTS | 27 | 0:00.01 |
| 4 | TABLE ACCESS BY INDEX ROWID EMP | 350K | 0:00.55 |
|* 5 | INDEX RANGE SCAN EMP_HD_IDX | 350K | 0:00.20 |
8 minutes → 0.85 seconds. The 7× cardinality miss disappeared once the optimiser had column histograms.
Quick Tuning Checklist
When a query is slow, ask in order:
- Get a real execution plan (with
GATHER_PLAN_STATISTICS) - Check E-Rows vs A-Rows — large discrepancies indicate stats problems
- Look for full scans on large tables → missing or unusable index
- Check pruning on partitioned tables → defeated by a function or implicit conversion
- Inspect join order — leading table should be the most selective one
- Look for sorts —
SORT GROUP BYorSORT ORDER BYon huge datasets may benefit from an index - Check parallelism — large data warehouse queries should use it
- Refresh statistics —
GATHER_TABLE_STATSwith histograms on skewed columns - Only then consider hints — and verify they actually changed the plan
Common Errors
| Error / Symptom | Likely Cause | Fix |
|---|---|---|
| Plan changes between runs | Bind variable peeking with skew | Use adaptive cursors or histograms |
A-Rows differs 10× from E-Rows |
Stale or missing statistics | Gather stats with column histograms |
| Hint silently ignored | Misspelled alias/table/index | Use exact alias names; verify in plan |
TABLE ACCESS FULL after creating an index |
Stats not refreshed; index unusable; query rewritten | Gather stats; check USER_INDEXES.STATUS |
INDEX RANGE SCAN slower than full scan |
Index has too many matching rows | Re-evaluate — full scan may genuinely be better |
MERGE JOIN CARTESIAN in plan |
Forgot a join predicate; correlated subquery cardinality error | Check JOIN ... ON conditions are present |
| Plan uses unexpected index | Optimiser thinks it's cheaper | Compare E-Rows to A-Rows; gather extended statistics |
| ORA-01789: query block has incorrect number of result columns | Bad set operation | Match column counts; not really an EXPLAIN issue but commonly mis-debugged |
Interview Corner
▶ Show answer
The two main culprits when "the query stopped working" without data changes:
The plan changed. The optimiser may have chosen a different plan because of:
- New statistics gathered overnight by the auto-stats job
- Bind variable peeking — the first query of the day used a different value than yesterday's, producing a different "shape" of plan that's bad for subsequent queries
- New histograms causing cardinality estimates to flip
Diagnostic: look at
V$SQL_PLAN/DBA_HIST_SQL_PLANto see plans for this SQL_ID over time. Compare the plan_hash_value.Resources changed.
- Other workload added on the server (memory pressure, I/O contention)
- Tablespace fragmentation
- Disk failure causing extra I/O on one mirror
Diagnostic: ASH / AWR reports for the time window;
V$SESSION_WAITto see what waits are accumulating.
Quick triage:
-- Look at all plans for this SQL_ID:
SELECT plan_hash_value, COUNT(*), AVG(elapsed_time/executions/1e6) AS avg_sec
FROM v$sql
WHERE sql_id = 'abc123'
GROUP BY plan_hash_value;
If you see two plans with very different avg_sec, the plan changed — and you can lock in the good one with a SQL Plan Baseline or SQL Profile.
▶ Show answer
Use NESTED LOOPS when the outer set is small (a few hundred rows or fewer) AND there's a fast lookup (index) on the inner set's join column.
Why: Nested loops iterates "for each row in outer, look up matches in inner". The cost is roughly outer_rows × inner_lookup_cost. With an index, the inner lookup is O(log N) — so the whole join is O(outer × log inner). Excellent for small outer sets.
Use HASH JOIN when both sides are large. Hash join builds a hash table on one side, then scans the other and probes the hash. The cost is roughly outer + inner — linear in both. Excellent for big-to-big joins.
Rule of thumb:
| Outer size | Inner has index? | Best join |
|---|---|---|
| Small (< 1000) | Yes | NESTED LOOPS |
| Small (< 1000) | No | HASH JOIN |
| Large | Yes or no | HASH JOIN |
| Both pre-sorted | n/a | MERGE JOIN |
The optimiser usually picks correctly. When it doesn't, the most common cause is cardinality misestimation — it thinks the outer is small when it's actually huge (or vice versa). Fix the statistics before hinting.
▶ Show answer
Most common reasons:
Wrong alias.
/*+ INDEX(employees emp_idx) */won't work if you aliased the table ase. The hint references the alias in scope, not the base table:SELECT /*+ INDEX(e emp_idx) */ * FROM employees e ...The hint conflicts with another hint or with a query rewrite that already happened. Example:
/*+ INDEX */on a column that doesn't have an index of that name — silently dropped.The optimiser determines the hint is unsafe — e.g., a
/*+ USE_HASH */on a join where one side has no joinable predicate.The hint targets a transformed query. After view merging or subquery unnesting, your alias might not exist anymore. The hint applies to the textually-typed query, not the rewritten one.
The hint syntax is malformed. Hints have strict syntax; a typo turns the comment from a hint into just a comment.
/* +INDEX */(extra space inside/*+) is silently ignored.
Verify the hint took effect:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => '+HINT_REPORT'));
This shows which hints were used, ignored, and why. Always check before relying on a hint in production.
Related Topics
- Performance — broader tuning techniques including stats and parallelism
- Indexes — index types and when each one is used by the optimiser
- Partitioning — partition pruning visible in execution plans
- Joins — join semantics; the plan shows which physical algorithm is chosen
- Data Dictionary —
V$SQL,V$SQL_PLAN, and other dictionary views for plan analysis