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.
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
| Statistic | What it tells the CBO | Where stored |
|---|---|---|
| num_rows | How many rows are in the table | dba_tables |
| blocks | How many blocks the table occupies on disk | dba_tables |
| num_distinct | How selective a column is (higher = more selective) | dba_tab_col_statistics |
| low_value / high_value | Range of values — used for range predicate estimates | dba_tab_col_statistics |
| density | Average fraction of rows per distinct value | dba_tab_col_statistics |
| clustering_factor | How well index order matches physical row order | dba_indexes |
| histograms | Non-uniform distribution of skewed column data | dba_histograms |
-- 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 );
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.
-- 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.
| Id | Operation | Name | Starts | E-Rows | A-Rows | Cost |
|---|---|---|---|---|---|---|
| 0 | SELECT STATEMENT | 1 | 23 | 18342 | ||
| 1 | HASH GROUP BY | 1 | 1200 | 23 | ||
| 2 | HASH JOIN | 1 | 4800 | 23 | ||
| 3 | TABLE ACCESS FULL | CUSTOMERS | 1 | 500 | 498 | 312 |
| 4 | HASH JOIN | 1 | 4800 | 23 | ||
| *5 | TABLE ACCESS FULL | ORDERSPROBLEM | 1 | 4800 | 23 | 8241 |
| 6 | TABLE ACCESS FULL | ORDER_ITEMS | 1 | 48000 | 230 | 9789 |
The three plan-reading rules
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.
-- 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 );
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.
| 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 |
| Id | Operation | Name | Rows | |----|----------------------|---------------|-------| | 1 | HASH JOIN | | 50000 | | 2 | TABLE ACCESS FULL | CUSTOMERS | 1000 | <- build side | 3 | TABLE ACCESS FULL | ORDERS | 50000 | <- probe side
| 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.
-- 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.
-- 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'));
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.
-- 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.
-- 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.
-- 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$SQLshows theplan_hash_valuechanged. 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,000butA-Rows = 3. The operation is aTABLE 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$SQLfor that sql_id and see 7 child cursors. What is happening and what doesIS_BIND_SENSITIVE = Ytell you? -
Q4
What is the difference between
IS_BIND_SENSITIVEandIS_BIND_AWAREinV$SQL? Which one means Oracle has already started generating different plans? -
Q5
You see a
NESTED LOOPSjoin where the inner child is aTABLE ACCESS FULLon 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?