SQL tuning is one of the most critical skills for an Oracle DBA. A poorly written query can bring an entire system to its knees, while a well-tuned one can run in milliseconds. This guide covers the most important techniques every Oracle DBA should know.
1. Understanding the Execution Plan
The execution plan is the roadmap Oracle uses to execute your SQL. Always start here when tuning.
Using EXPLAIN PLAN
EXPLAIN PLAN FOR
SELECT
e.employee_id,
e.first_name,
e.last_name,
d.department_name
FROM
employees e
JOIN departments d
ON e.department_id = d.department_id
WHERE
e.salary > 5000
ORDER BY
e.last_name;
-- Display the plan
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
Using DBMS_XPLAN with actual runtime statistics
-- Step 1: Run the query with the hint
SELECT /*+ gather_plan_statistics */
e.employee_id,
e.first_name,
e.last_name,
d.department_name
FROM
employees e
JOIN departments d
ON e.department_id = d.department_id
WHERE
e.salary > 5000;
-- Step 2: Pull the actual execution plan
SELECT *
FROM
TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
sql_id => NULL,
cursor_child_no => 0,
format => 'ALLSTATS LAST'
)
);
2. Gathering Statistics
Stale or missing statistics are the number one cause of bad execution plans in Oracle.
Gather stats on a single table
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'HR',
tabname => 'EMPLOYEES',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
cascade => TRUE,
degree => 4
);
END;
/
Gather stats on an entire schema
BEGIN
DBMS_STATS.GATHER_SCHEMA_STATS(
ownname => 'HR',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
cascade => TRUE,
degree => 4
);
END;
/
Check when statistics were last gathered
SELECT
table_name,
num_rows,
last_analyzed,
stale_stats
FROM
dba_tab_statistics
WHERE
owner = 'HR'
ORDER BY
last_analyzed ASC NULLS FIRST;
3. Index Usage and Management
Check if an index is being used
-- Enable monitoring on the index
ALTER INDEX hr.emp_salary_idx MONITORING USAGE;
-- After running your workload, check usage
SELECT
index_name,
table_name,
monitoring,
used,
start_monitoring,
end_monitoring
FROM
v$object_usage
WHERE
index_name = 'EMP_SALARY_IDX';
Find high-cost SQL candidates for indexing
SELECT
sql_id,
executions,
disk_reads,
buffer_gets,
ROUND(buffer_gets / NULLIF(executions, 0)) AS gets_per_exec,
SUBSTR(sql_text, 1, 100) AS sql_snippet
FROM
v$sqlstats
WHERE
disk_reads / NULLIF(executions, 0) > 1000
ORDER BY
disk_reads DESC
FETCH FIRST 20 ROWS ONLY;
Create a function-based index
-- Useful when queries filter on UPPER(column)
CREATE INDEX hr.emp_upper_lastname_idx
ON hr.employees ( UPPER(last_name) );
-- This query will now use the index
SELECT
employee_id,
first_name,
last_name
FROM
hr.employees
WHERE
UPPER(last_name) = 'KING';
4. SQL Hints
Hints are directives you give to the Oracle optimizer. Use them carefully — they override the optimizer’s decisions. Always fix the root cause (statistics, indexes) before resorting to hints.
Force an index
SELECT /*+ INDEX(e emp_salary_idx) */
e.employee_id,
e.first_name,
e.salary
FROM
hr.employees e
WHERE
e.salary BETWEEN 5000 AND 10000;
Force a Full Table Scan
-- Sometimes faster when returning a large percentage of rows
SELECT /*+ FULL(e) */
e.employee_id,
e.first_name,
e.salary
FROM
hr.employees e
WHERE
e.department_id = 90;
Control join methods
-- Force a Hash Join (good for large tables)
SELECT /*+ USE_HASH(e d) */
e.employee_id,
e.last_name,
d.department_name
FROM
hr.employees e
JOIN hr.departments d
ON e.department_id = d.department_id;
-- Force a Nested Loop Join (good for small driving sets)
SELECT /*+ USE_NL(e d) */
e.employee_id,
e.last_name,
d.department_name
FROM
hr.employees e
JOIN hr.departments d
ON e.department_id = d.department_id;
Parallel query hint
SELECT /*+ PARALLEL(e, 4) */
department_id,
COUNT(*) AS headcount,
AVG(salary) AS avg_salary
FROM
hr.employees e
GROUP BY
department_id
ORDER BY
avg_salary DESC;
5. Identifying Top SQL from AWR
-- Top 10 SQL statements by elapsed time from the last AWR snapshot
SELECT *
FROM (
SELECT
s.sql_id,
ROUND(s.elapsed_time_delta / 1e6, 2) AS elapsed_secs,
s.executions_delta AS executions,
ROUND(s.elapsed_time_delta / NULLIF(s.executions_delta, 0) / 1e6, 3) AS secs_per_exec,
SUBSTR(t.sql_text, 1, 100) AS sql_snippet
FROM
dba_hist_sqlstat s
JOIN dba_hist_sqltext t
ON s.sql_id = t.sql_id
WHERE
s.snap_id = (
SELECT MAX(snap_id)
FROM dba_hist_snapshot
)
ORDER BY
s.elapsed_time_delta DESC
)
WHERE ROWNUM <= 10;
6. SQL Tuning Advisor
Oracle’s built-in advisor can automatically suggest improvements for a specific SQL_ID.
-- Step 1: Create and execute the tuning task
DECLARE
l_task_name VARCHAR2(30);
BEGIN
l_task_name := DBMS_SQLTUNE.CREATE_TUNING_TASK(
sql_id => 'your_sql_id_here',
scope => DBMS_SQLTUNE.SCOPE_COMPREHENSIVE,
time_limit => 60,
task_name => 'tune_my_query',
description => 'Tuning task for slow query'
);
DBMS_SQLTUNE.EXECUTE_TUNING_TASK(
task_name => 'tune_my_query'
);
END;
/
-- Step 2: View the recommendations
SELECT DBMS_SQLTUNE.REPORT_TUNING_TASK('tune_my_query')
FROM dual;
7. Common Tuning Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
SELECT * |
Fetches unnecessary columns, prevents index-only scans | Select only needed columns |
| Functions on indexed columns in WHERE clause | Prevents index usage | Use function-based indexes |
| Implicit data type conversion | Causes full table scans | Match data types explicitly |
OR conditions on different columns |
Optimizer struggles to use indexes | Rewrite as UNION ALL |
| Missing bind variables | Hard parses, high CPU, library cache contention | Always use bind variables |
| Outdated or missing statistics | Bad cardinality estimates, wrong join methods | Gather stats regularly |
Key Takeaways
- Always check the execution plan first before making any changes.
- Statistics drive the optimizer — keep them fresh and accurate.
- Indexes are not always faster — for large result sets a full table scan can be better.
- Hints are a last resort — fix the root cause first.
- Use AWR and SQL Tuning Advisor — Oracle gives you powerful built-in tools, use them.