Chapter 03 · Core SQL Engine

SQL Execution & the Oracle Optimizer

Every performance problem you'll ever face traces back here eventually. Understanding how Oracle decides to run your SQL — and how to read, influence, and fix that decision — is the single most powerful skill in performance tuning.

Parse / Bind / Execute / Fetch CBO Statistics Execution Plans Bind Peeking Join Methods SQL Plan Baselines

The Four Phases of SQL Execution

Before the optimizer enters the picture, your SQL goes through four distinct phases. Knowing which phase is expensive tells you exactly where to focus your tuning effort.

1 — Parse
2 — Bind
3 — Execute
4 — Fetch
Phase 1 — Parse
Oracle reads and understands your SQL. This is where the optimizer lives. A hard parse is 10–100× more expensive than a soft parse.
Syntax check
Is the SQL grammatically valid? SELECT, FROM, WHERE in the right places?
Semantic check
Do the tables and columns exist? Does the user have SELECT privilege?
Shared pool lookup
Has this exact SQL been parsed before? YES → soft parse (cheap). NO → hard parse (expensive).
CBO plan generation
Hard parse only: optimizer generates candidate plans, estimates cost of each, picks the cheapest one.
Hard parse is 10–100× more expensive than soft parse. A system doing 10,000 hard parses/sec is wasting massive CPU. Always use bind variables.
Phase 2 — Bind
Bind variable values are substituted into the parsed plan before execution. This is where bind peeking can cause "suddenly slow" problems.
Bind substitution
Oracle plugs actual values for :b1, :b2 etc. into the plan from Phase 1.
Bind peeking
On the FIRST hard parse, Oracle peeks at the actual bind value to estimate selectivity. This plan is reused for ALL future values.
Adaptive cursor sharing (11g+)
If bind values lead to very different row counts, Oracle can generate multiple child cursors — one per selectivity bucket.
The peeking trap
If :b1 = 'RARE_VALUE' was peeked first, the plan uses an index. Later, :b1 = 'COMMON_VALUE' (98% of rows) reuses that same plan — catastrophic.
Bind variable peeking is the most common cause of "the query was fast yesterday, slow today" — a new bind value caused Oracle to generate a plan that's terrible for most other values.
Phase 3 — Execute
Oracle actually runs the plan — accessing rows, joining tables, applying filters. Almost all wait events appear here for SELECT statements.
Row source tree
Oracle builds a tree of row sources. Each node produces rows for its parent. The root node produces the final result set.
Buffer cache checks
Every block access checks the buffer cache first. Miss = physical I/O = wait event (db file sequential/scattered read).
Join execution
Nested loops, hash joins, or sort-merge joins are executed. Hash joins build a hash table in PGA memory.
DML — undo + redo
For INSERT/UPDATE/DELETE: undo blocks are written first, then data blocks are modified, then redo is generated.
For SELECT: almost all wait events appear here. For DML: log file sync and enqueue waits also appear here on COMMIT.
Phase 4 — Fetch
Rows are returned to the client in batches. This phase is often overlooked but has enormous impact on network-heavy applications.
Array fetch size
Oracle sends rows in batches. Default JDBC: 10–15 rows. Optimal: 100–500 rows. 1 row at a time = 1 network round-trip per row.
Cursor stays open
Cursor stays open between fetches. Undo must be retained for read consistency — long fetches can cause ORA-01555.
ORA-01555 risk
If fetch takes too long and undo blocks are overwritten, Oracle raises "snapshot too old". Common on slow reports on busy systems.
LAST_CALL_ET
V$SESSION.LAST_CALL_ET shows seconds since last client call. High value on ACTIVE session = stuck in fetch phase, not executing.
A Java app fetching 1M rows one at a time makes 1M round trips. Set defaultRowPrefetch=500 in JDBC. This single change can cut network time by 98%.

The Cost-Based Optimizer (CBO)

The CBO decides which indexes to use, which table to access first, and how to join tables together. It makes these decisions by estimating the cost — a proxy for I/O + CPU — of every candidate plan. Its accuracy is entirely dependent on statistics. Wrong statistics = wrong plan.

What the CBO uses to estimate cost

StatisticWhat it tells the CBOWhere stored
num_rowsHow many rows are in the tabledba_tables
blocksHow many blocks the table occupies on diskdba_tables
num_distinctHow selective a column is (higher = more selective)dba_tab_col_statistics
low_value / high_valueRange of values — used for range predicate estimatesdba_tab_col_statistics
densityAverage fraction of rows per distinct valuedba_tab_col_statistics
clustering_factorHow well index order matches physical row orderdba_indexes
histogramsNon-uniform distribution of skewed column datadba_histograms
SQL — Check stale statistics on key tables
-- Check when stats were last gathered — NULLs = NEVER analyzed!
SELECT owner, table_name, num_rows, blocks,
       last_analyzed,
       ROUND(SYSDATE - last_analyzed, 0) AS days_since_analyzed
FROM   dba_tables
WHERE  owner = 'APPS'
ORDER BY last_analyzed ASC NULLS FIRST;

-- Find tables with stale stats (>10% rows changed since last analyze)
SELECT owner, table_name, num_rows,
       stale_stats, last_analyzed
FROM   dba_tab_statistics
WHERE  owner = 'APPS'
AND    stale_stats = 'YES'
ORDER BY num_rows DESC;

-- Gather stats with histogram on a skewed column (e.g. STATUS)
EXEC DBMS_STATS.GATHER_TABLE_STATS(
  ownname    => 'APPS',
  tabname    => 'ORDERS',
  method_opt => 'FOR COLUMNS STATUS SIZE AUTO',
  cascade    => TRUE
);
⚠ The skew problem
Without a histogram on STATUS (3 values: OPEN=2%, CLOSED=96%, CANCELLED=2%), the CBO assumes uniform distribution and estimates 33% of rows for any STATUS predicate. It chooses a full table scan for OPEN (correct for CLOSED, catastrophic for OPEN). A histogram fixes this — the CBO then knows OPEN = 2% → use index, CLOSED = 96% → full scan.

Reading Execution Plans

This is where most 1-year tuners are weakest. An execution plan tells you exactly what Oracle decided to do and, with runtime stats, whether that decision was right.

SQL — Pull plan from cursor cache (best method — shows actual stats)
-- Method 1: From cursor cache — ALLSTATS LAST shows actual row counts
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(
  sql_id          => 'abc123def456',
  cursor_child_no => NULL,
  format          => 'ALLSTATS LAST +PREDICATE +COST'
));

-- Method 2: EXPLAIN PLAN (estimated only — no runtime stats)
EXPLAIN PLAN FOR
SELECT o.order_id, c.name, SUM(oi.qty)
FROM   orders o
JOIN   customers c  ON c.id = o.customer_id
JOIN   order_items oi ON oi.order_id = o.order_id
WHERE  o.status = 'OPEN'
GROUP BY o.order_id, c.name;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, NULL, 'ALL'));

-- Method 3: For a currently running session
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(
  sql_id => (SELECT sql_id FROM v$session WHERE sid = 142)
));

Annotated real execution plan

Here is a real plan with problem annotations. Learn to read every column before diagnosing anything.

Execution plan — with ALLSTATS LAST (E-Rows = estimated, A-Rows = actual)
IdOperationNameStartsE-RowsA-RowsCost
0SELECT STATEMENT12318342
1  HASH GROUP BY1120023
2    HASH JOIN1480023
3      TABLE ACCESS FULLCUSTOMERS1500498312
4      HASH JOIN1480023
*5        TABLE ACCESS FULLORDERSPROBLEM14800238241
6        TABLE ACCESS FULLORDER_ITEMS1480002309789
⚠ Line 5: E-Rows = 4,800 but A-Rows = 23. The CBO estimated 4,800 rows from ORDERS but only got 23. This cardinality misestimate caused it to choose a full table scan (cost 8,241) instead of an index lookup. The predicate filter "STATUS='OPEN' AND ORDER_DATE > SYSDATE-30" is far more selective than the CBO knew.

The three plan-reading rules

Rule 1 — Read innermost first
Execution starts at the deepest indented line (lines 3, 5, 6), not at line 0. The root (line 0) is the last thing that runs.
Rule 2 — The asterisk (*) marks a filter predicate
An asterisk on a line means Oracle is applying a WHERE filter at that operation — check "Predicate Information" at the bottom of the plan to see the exact filter.
Rule 3 — Compare E-Rows vs A-Rows (the most important check)
A large gap between estimated and actual rows is a cardinality misestimate — the #1 cause of bad plans. It means the CBO is flying blind. Fix: gather stats, add histograms, or use SQL Profile.

Cardinality Misestimates

A cardinality misestimate is when the CBO's row count estimate is wildly different from the actual row count. It is the single most common root cause of bad execution plans.

SQL — Diagnose cardinality and histograms
-- Check column stats for predicate columns — look for missing histograms
SELECT column_name, num_distinct, num_nulls,
       density, histogram, num_buckets,
       last_analyzed
FROM   dba_tab_col_statistics
WHERE  table_name = 'ORDERS'
AND    owner      = 'APPS'
AND    column_name IN ('STATUS','CUSTOMER_ID','ORDER_DATE');

-- Histogram type meanings:
-- NONE        = no histogram (CBO assumes uniform distribution)
-- FREQUENCY   = one bucket per distinct value (low cardinality, skewed)
-- TOP-FREQUENCY = top N values tracked (12c+)
-- HEIGHT BALANCED = N buckets, equal rows per bucket
-- HYBRID      = combines frequency + height balanced (12c+, best)

-- Gather with histogram for skewed column:
EXEC DBMS_STATS.GATHER_TABLE_STATS(
  ownname    => 'APPS',
  tabname    => 'ORDERS',
  method_opt => 'FOR COLUMNS STATUS SIZE AUTO, ORDER_DATE SIZE AUTO',
  cascade    => TRUE
);
🔎 Real scenario — the skew trap
ORDERS has 10 million rows. STATUS has 3 values: CLOSED (96%), OPEN (1.8%), CANCELLED (0.2%). Without a histogram, CBO assumes 33% per value. For WHERE STATUS = 'OPEN', it estimates 3.3 million rows → chooses full table scan. Actual rows = 180,000 → index would have been 10× faster. Adding a histogram: CBO now knows OPEN = 1.8% → chooses index. Query drops from 45 seconds to 0.4 seconds.

The Three Join Methods

The CBO chooses a join method based on table sizes, available indexes, and PGA memory. Understanding when each method is chosen — and when it's the wrong choice — lets you fix bad join decisions.

Nested Loops
Hash Join
Sort-Merge Join
Nested Loops Join
For every row in the outer (driving) table, Oracle looks up matching rows in the inner table — usually via an index. Think of it as two nested for-loops. Excellent for small driving sets with indexed inner tables. Terrible if the outer set is large.
Best for
Small driving row set + indexed inner table. OLTP lookups returning a few rows.
Memory use
Minimal — no in-memory build phase. Works well with tiny PGA.
Watch for
TABLE ACCESS FULL as the inner child = missing index. Each outer row triggers a full scan.
Plan pattern — nested loops
| Id | Operation                  | Name          | Rows |
|----|----------------------------|---------------|------|
|  1 |  NESTED LOOPS              |               |   10 |
|  2 |   TABLE ACCESS BY INDEX    | ORDERS        |   10 |
|* 3 |    INDEX RANGE SCAN        | IDX_ORD_DATE  |   10 |
|  4 |   TABLE ACCESS BY INDEX    | CUSTOMERS     |    1 |
|* 5 |    INDEX UNIQUE SCAN       | PK_CUSTOMERS  |    1 |
Hash Join
Oracle reads the smaller table entirely, builds a hash table from it in PGA memory (the "build side"), then streams the larger table and probes the hash table for matches (the "probe side"). One pass through each table — very efficient for large unsorted datasets.
Best for
Large table joins where neither table has a useful index. Analytics and batch workloads.
Memory use
High — smaller table must fit in PGA hash area. Spills to temp if it doesn't.
Watch for
Wrong side chosen as build side (should be smaller table). Spill = direct path read/write temp waits.
Plan pattern — hash join
| Id | Operation            | Name          | Rows  |
|----|----------------------|---------------|-------|
|  1 |  HASH JOIN           |               | 50000 |
|  2 |   TABLE ACCESS FULL  | CUSTOMERS     |  1000 |  <- build side
|  3 |   TABLE ACCESS FULL  | ORDERS        | 50000 |  <- probe side
Sort-Merge Join
Oracle sorts both inputs on the join key, then merges them like a zipper. Rarely chosen by the CBO today — hash join is almost always cheaper. Appears for inequality joins (>, <) and when data is already sorted from an index.
Best for
Data already pre-sorted (from index). Inequality joins. Range-band joins.
Memory use
High — both sides sorted in PGA. Both can spill to temp independently.
Watch for
SORT JOIN on both sides — two sorts, both potentially spilling = double temp I/O.
Plan pattern — sort-merge join
| Id | Operation            | Name     | Rows  |
|----|----------------------|----------|-------|
|  1 |  MERGE JOIN          |          | 50000 |
|  2 |   SORT JOIN          |          |  1000 |
|  3 |    TABLE ACCESS FULL | CUST     |  1000 |
|* 4 |   SORT JOIN          |          | 50000 |
|  5 |    TABLE ACCESS FULL | ORDERS   | 50000 |

Hard Parse vs Soft Parse

Every SQL statement must be parsed before execution. A hard parse is a full parse including plan generation — expensive. A soft parse finds the cursor in the library cache — cheap. The difference is bind variables.

SQL — System-wide hard parse rate + top offenders
-- System-wide hard parse rate (target: hard/total < 5%)
SELECT
  hp.value                                         AS hard_parses,
  tp.value                                         AS total_parses,
  ROUND(hp.value * 100 / NULLIF(tp.value, 0), 1)  AS hard_parse_pct
FROM   v$sysstat hp, v$sysstat tp
WHERE  hp.name = 'parse count (hard)'
AND    tp.name = 'parse count (total)';

-- Find top hard-parse offenders
SELECT sql_id, parse_calls, executions,
       ROUND(parse_calls / NULLIF(executions,0), 2) AS parse_per_exec,
       SUBSTR(sql_text, 1, 80)                       AS sql_snippet
FROM   v$sqlarea
WHERE  parse_calls > 500
ORDER BY parse_calls DESC
FETCH FIRST 20 ROWS ONLY;

-- The smoking gun: SQL with literals — same query, different WHERE values
-- force_matching_signature groups SQL that are identical except for literals
SELECT force_matching_signature,
       COUNT(DISTINCT sql_id)  AS variant_count,
       SUM(parse_calls)         AS total_parses,
       MIN(SUBSTR(sql_text,1,100)) AS sample_sql
FROM   v$sqlarea
WHERE  parsing_schema_name NOT IN ('SYS','SYSTEM')
GROUP BY force_matching_signature
HAVING   COUNT(DISTINCT sql_id) > 50
ORDER BY total_parses DESC
FETCH FIRST 10 ROWS ONLY;

Bind Variable Peeking

Bind peeking is the most common cause of "the query was fast yesterday, slow today." On the first hard parse, Oracle peeks at the actual bind value and generates a plan for that specific value. That plan is then reused for all future values — even if those values have completely different selectivity.

SQL — Diagnose bind peeking and adaptive cursor sharing
-- Find SQL with multiple child cursors (each child = a different plan)
SELECT sql_id,
       COUNT(*)         AS child_cursors,
       SUM(executions)  AS total_executions
FROM   v$sql
WHERE  parsing_schema_name NOT IN ('SYS','SYSTEM')
GROUP BY sql_id
HAVING   COUNT(*) > 3
ORDER BY child_cursors DESC
FETCH FIRST 20 ROWS ONLY;

-- See WHY each child cursor has a different plan
SELECT child_number,
       executions,
       rows_processed,
       is_bind_sensitive,   -- Y = diff binds could give diff plans
       is_bind_aware,       -- Y = adaptive cursor sharing active
       is_shareable
FROM   v$sql
WHERE  sql_id = '&sql_id'
ORDER BY child_number;

-- Compare plans for two different child cursors
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('&sql_id', 0, 'BASIC ROWS'));
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('&sql_id', 1, 'BASIC ROWS'));
🔎 Real scenario — bind peeking disaster
App runs SELECT * FROM orders WHERE customer_id = :b1. Customer 1 (Amazon) has 4 million orders — 30% of the table. Customer 9999 (small vendor) has 5 orders. Oracle peeked at :b1 = 9999 first, generated an index plan. Later, Customer 1's data is queried — Oracle reuses the index plan, causing 4 million single-block reads instead of a fast full table scan. Query takes 45 minutes instead of 2 seconds. Fix: flush the cursor and let Oracle re-peek, or use SQL Plan Baselines to pin the right plan per case.

SQL Tuning Advisor

For a complex query you can't easily diagnose, let Oracle analyze it. The advisor gives concrete recommendations: create this index, gather these stats, accept this SQL profile.

SQL — Create and run SQL Tuning Advisor task
-- Step 1: Create and execute a tuning task
DECLARE
  l_task VARCHAR2(30);
BEGIN
  l_task := DBMS_SQLTUNE.CREATE_TUNING_TASK(
    sql_id     => 'abc123def456',
    scope      => DBMS_SQLTUNE.SCOPE_COMPREHENSIVE,
    time_limit => 120,          -- seconds
    task_name  => 'TUNE_ORDER_QUERY'
  );
  DBMS_SQLTUNE.EXECUTE_TUNING_TASK(task_name => 'TUNE_ORDER_QUERY');
END;
/

-- Step 2: Read the recommendations
SELECT DBMS_SQLTUNE.REPORT_TUNING_TASK('TUNE_ORDER_QUERY') FROM DUAL;

-- Step 3: Accept a SQL Profile if recommended
EXEC DBMS_SQLTUNE.ACCEPT_SQL_PROFILE(
  task_name => 'TUNE_ORDER_QUERY',
  replace   => TRUE
);

Plan Stability — SQL Plan Baselines (SPM)

Once you have a good plan, you want to keep it. Stats refreshes, patches, and parameter changes can silently change a plan. SQL Plan Baselines pin a specific plan so Oracle only uses it — or better plans verified by you.

SQL — Create and manage SQL Plan Baselines
-- Load the current good plan from cursor cache into SPM
DECLARE
  l_plans PLS_INTEGER;
BEGIN
  l_plans := DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE(
    sql_id          => 'abc123def456',
    plan_hash_value => 1234567890   -- from V$SQL
  );
  DBMS_OUTPUT.PUT_LINE('Plans loaded: ' || l_plans);
END;
/

-- Check what baselines exist
SELECT sql_handle, plan_name, enabled, accepted, fixed,
       elapsed_time, executions
FROM   dba_sql_plan_baselines
WHERE  sql_text LIKE '%orders%'
ORDER BY last_executed DESC;

-- Disable a baseline (stop using it without deleting)
DECLARE
  l_cnt PLS_INTEGER;
BEGIN
  l_cnt := DBMS_SPM.ALTER_SQL_PLAN_BASELINE(
    sql_handle  => '&sql_handle',
    plan_name   => '&plan_name',
    attribute_name  => 'ENABLED',
    attribute_value => 'NO'
  );
END;
/

Complete Slow SQL Triage Workflow

Run these queries in order on any slow SQL. They answer every diagnostic question systematically.

01
Find the SQL from ASH or V$SESSION
Search by SQL text, or get sql_id from the session that's slow right now.
02
Check execution stats — consistent or intermittent?
avg_ela_secs, avg_lios (logical I/O), avg_pios (physical I/O), avg_rows from V$SQL.
03
Pull execution plan with ALLSTATS LAST
Look for E-Rows vs A-Rows gaps, TABLE ACCESS FULL where index expected, wrong join methods.
04
Check statistics freshness on plan tables
last_analyzed, num_rows, stale_stats in dba_tab_statistics. Stale stats = likely source of cardinality errors.
05
Check column stats on predicate columns
num_distinct, density, histogram type for WHERE clause columns. Missing histogram on skewed column = likely cardinality error.
06
Apply fix and compare plan_hash_value before and after
If plan_hash_value changed AND avg_ela_secs dropped significantly — fix worked. If hash didn't change — Oracle is still using the old plan (check baselines, cursor cache).
SQL — Complete triage queries (run in order)
-- STEP 1: Find the SQL
SELECT sql_id, sql_text
FROM   v$sql
WHERE  sql_text LIKE '%orders%status%'
AND    parsing_schema_name = 'APPS'
ORDER BY elapsed_time DESC
FETCH FIRST 5 ROWS ONLY;

-- STEP 2: Check execution stats
SELECT sql_id, executions,
       ROUND(elapsed_time/1e6/NULLIF(executions,0), 2)  AS avg_ela_secs,
       ROUND(cpu_time/1e6/NULLIF(executions,0), 2)    AS avg_cpu_secs,
       ROUND(buffer_gets/NULLIF(executions,0))          AS avg_lios,
       ROUND(disk_reads/NULLIF(executions,0))           AS avg_pios,
       ROUND(rows_processed/NULLIF(executions,0))        AS avg_rows
FROM   v$sql
WHERE  sql_id = '&sql_id';

-- STEP 3: Pull the execution plan with actual runtime stats
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(
  '&sql_id', NULL, 'ALLSTATS LAST +PREDICATE +COST'
));

-- STEP 4: Check statistics freshness on tables in the plan
SELECT table_name, num_rows, last_analyzed,
       ROUND(SYSDATE - last_analyzed) AS days_old
FROM   dba_tables
WHERE  table_name IN ('ORDERS','CUSTOMERS','ORDER_ITEMS')
AND    owner = 'APPS';

-- STEP 5: Check column stats on predicate columns
SELECT column_name, num_distinct, num_nulls, density,
       histogram, num_buckets
FROM   dba_tab_col_statistics
WHERE  table_name = 'ORDERS'
AND    owner = 'APPS'
AND    column_name IN ('STATUS','ORDER_DATE','CUSTOMER_ID');

Self-Check Questions

Before moving to Chapter 4, make sure you can answer these from memory.

  • Q1 A query suddenly got slow after a weekly statistics gather. Before the gather it took 0.2s, now it takes 45s. V$SQL shows the plan_hash_value changed. What happened and what are your two fastest options to restore the old plan?
  • Q2 You pull an execution plan and see E-Rows = 1,200,000 but A-Rows = 3. The operation is a TABLE ACCESS FULL. What is the root cause and what do you do first?
  • Q3 A developer says "bind variables are already used but the query is still sometimes slow." You look at V$SQL for that sql_id and see 7 child cursors. What is happening and what does IS_BIND_SENSITIVE = Y tell you?
  • Q4 What is the difference between IS_BIND_SENSITIVE and IS_BIND_AWARE in V$SQL? Which one means Oracle has already started generating different plans?
  • Q5 You see a NESTED LOOPS join where the inner child is a TABLE ACCESS FULL on a 10-million-row table. What is almost certainly missing, what wait event will you see in ASH for sessions running this query, and how does the E-Rows vs A-Rows comparison help confirm the diagnosis?