Tuning an Oracle query is mostly one skill: getting the real execution plan, and comparing what the optimiser estimated against what actually happened. Everything else follows from that comparison. This post covers the index types, how to read a plan, and the handful of mistakes that account for most slow queries.
Index types you will actually use
| Type | When |
|---|---|
| B-tree | The default, and the answer 95% of the time. Balanced, good for equality and ranges. |
| Unique | Created automatically by PRIMARY KEY and UNIQUE. |
| Composite | Several columns. Column order is everything — see below. |
| Function-based | Indexes an expression, e.g. UPPER(email). |
| Bitmap | Low-cardinality columns in a data warehouse. Never in OLTP — one row change locks a whole bitmap segment, so concurrent DML serialises. |
| Reverse key | Reverses the bytes to spread sequential inserts. A RAC-specific fix for index block contention; it makes range scans impossible. |
CREATE INDEX orders_customer_ix ON orders (customer_id);
CREATE INDEX orders_cust_date_ix ON orders (customer_id, order_date DESC);
CREATE UNIQUE INDEX customers_email_uix ON customers (lower(email));
CREATE INDEX customers_upper_name_ix ON customers (upper(last_name));
-- Build without locking out DML, and gather stats while you are there
CREATE INDEX orders_status_ix ON orders (status) ONLINE COMPUTE STATISTICS;Composite indexes: the leading-column rule
An index on (a, b, c) can be used for a predicate on a, on
(a, b), or on (a, b, c). It is generally not usable for a predicate
on b alone, because the entries are sorted by a first — like looking up
someone in a phone book when you only know the first name.
CREATE INDEX ix ON orders (customer_id, order_date, status);
WHERE customer_id = 42 -- uses it
WHERE customer_id = 42 AND order_date > sysdate - 7 -- uses it, both columns
WHERE order_date > sysdate - 7 -- generally cannot
WHERE customer_id = 42 AND status = 'NEW' -- uses customer_id, filters statusSo order the columns by how they are queried: equality predicates first, then the range predicate, then columns you only want along for the ride.
The last of those is a covering index. If every column the query touches is in the index, Oracle
never visits the table at all — the plan says INDEX RANGE SCAN with no
TABLE ACCESS BY INDEX ROWID under it, and that is usually the single biggest win
available:
-- Answered entirely from the index
CREATE INDEX orders_cover_ix ON orders (customer_id, order_date, total);
SELECT order_date, total FROM orders WHERE customer_id = 42;Oracle can also do an index skip scan, which probes the index once per distinct
leading value — so a predicate on b alone is not impossible, just only viable
when a has very few distinct values. Don't design around it.
Why your index is being ignored
1. A function on the indexed column
-- Index on last_name is unusable: the optimiser has no index on UPPER(last_name)
SELECT * FROM customers WHERE upper(last_name) = 'SMITH';
-- Index on order_date is unusable
SELECT * FROM orders WHERE trunc(order_date) = DATE '2024-03-18';
-- Fixes: rewrite so the column is bare...
SELECT * FROM orders
WHERE order_date >= DATE '2024-03-18' AND order_date < DATE '2024-03-19';
-- ...or index the expression instead
CREATE INDEX customers_upper_ix ON customers (upper(last_name));Implicit conversion counts as a function, and it is invisible. Comparing a
VARCHAR2 column to a number makes Oracle apply TO_NUMBER to
the column, killing the index:
-- account_no is VARCHAR2. This becomes TO_NUMBER(account_no) = 12345 → full scan
SELECT * FROM accounts WHERE account_no = 12345;
-- Quote it
SELECT * FROM accounts WHERE account_no = '12345';Look for SYS_OP_C2C or a TO_NUMBER around a column name in the plan's
predicate section — that is the fingerprint.
2. NULLs are not in a single-column B-tree index
Oracle does not store entirely-null keys. Two consequences:
-- Cannot use an index on status: the rows it wants are the ones not in the index
SELECT * FROM orders WHERE status IS NULL;
-- Cannot use a single-column index on total either — it might miss null rows
SELECT count(*) FROM orders;
-- Both work if the index cannot be all-null. A NOT NULL column, or a constant:
CREATE INDEX orders_status_ix ON orders (status, 1);That , 1 is a real trick, not a joke: the second key is never null, so every row gets
an index entry, and IS NULL becomes an index range scan.
3. A full scan is genuinely cheaper
Reading 40% of a table through an index means roughly one random I/O per row plus the index traversal; a full scan reads it in large sequential multi-block reads. The optimiser knows this. A full table scan is not a bug. On a 500-row lookup table it is always the right answer.
4. The statistics are wrong
The optimiser is a cost model fed by statistics. Stale statistics mean a plan chosen for a table shape that no longer exists — the usual cause of "it was fast yesterday".
-- When were these last gathered, and how far off is the row count?
SELECT table_name, num_rows, last_analyzed, stale_stats
FROM user_tab_statistics WHERE table_name = 'ORDERS';
BEGIN
dbms_stats.gather_table_stats(
ownname => user,
tabname => 'ORDERS',
cascade => TRUE, -- indexes too
estimate_percent => dbms_stats.auto_sample_size,
method_opt => 'FOR ALL COLUMNS SIZE AUTO' -- histograms where they help
);
END;
/Histograms are what let the optimiser know that status = 'NEW' matches 200 rows while
status = 'CLOSED' matches 40 million. Without one it assumes an even spread across
distinct values and picks a single plan for both — which is wrong for one of them.
Getting the plan
EXPLAIN PLAN — the estimate
EXPLAIN PLAN FOR
SELECT c.last_name, sum(o.total)
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE o.order_date >= DATE '2024-01-01'
GROUP BY c.last_name;
SELECT * FROM dbms_xplan.display(format => 'ALL +OUTLINE');This is what the optimiser would do. It does not run the query, and — importantly — it does not peek at your bind values, so the plan it shows can differ from the plan you get.
DISPLAY_CURSOR — what actually happened
This is the one to use. Run the query with a hint that collects row counts, then ask for the plan with actuals beside the estimates:
SELECT /*+ GATHER_PLAN_STATISTICS */ c.last_name, sum(o.total)
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE o.order_date >= DATE '2024-01-01'
GROUP BY c.last_name;
SELECT * FROM dbms_xplan.display_cursor(format => 'ALLSTATS LAST');--------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | A-Rows |
--------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 842 |
| 1 | HASH GROUP BY | | 1 | 841 | 842 |
|* 2 | HASH JOIN | | 1 | 1200 | 121043 |
| 3 | TABLE ACCESS FULL | CUSTOMERS | 1 | 842 | 842 |
|* 4 | INDEX RANGE SCAN | ORDERS_DATE_IX | 1 | 1200 | 121043 |
--------------------------------------------------------------------------------------Compare E-Rows with A-Rows. That comparison is the whole
technique. Above, the optimiser expected 1,200 rows from the index and got 121,043 — off by
100×. Every decision above that line was made on a bad number. Fix the estimate (statistics, a
histogram, a rewritten predicate) and the plan usually fixes itself. Tuning by adding hints to a plan
built on a wrong estimate is how you end up with SQL nobody can maintain.
Also read:
Starts— how many times the operation ran. A nested loop withStarts = 121043is doing 121,043 index lookups.A-TimeandBuffers(inALLSTATS) — where the work went. Buffer gets are a better signal than elapsed time because they don't vary with cache state.OMem/1Mem/Used-Memand a1in theO/1/Mcolumn — a sort or hash that spilled toTEMP.- The
Predicate Informationblock below the table.accessmeans the predicate drove the index;filtermeans rows were fetched and then thrown away. Turning a filter into an access predicate is often the whole fix.
In the client
SET AUTOTRACE ON EXPLAIN STATISTICS -- SQL*Plus and SQLcl: plan + I/O per statement
SET TIMING ONBind variables
Every distinct SQL text gets parsed and stored in the shared pool. Literals make every execution a new statement:
-- Three statements, three hard parses, three plans in the shared pool
SELECT * FROM orders WHERE customer_id = 1;
SELECT * FROM orders WHERE customer_id = 2;
SELECT * FROM orders WHERE customer_id = 3;
-- One statement, parsed once, executed three times
SELECT * FROM orders WHERE customer_id = :id;On a busy OLTP system, literal SQL causes latch contention in the shared pool and a measurable
throughput ceiling. It is also, not coincidentally, how SQL injection happens. JDBC
PreparedStatement and JPA both bind by default, so this is mostly a problem in
hand-built dynamic SQL and in reporting tools.
The flip side: with one plan for all bind values, a skewed column can get a plan that suits
status = 'CLOSED' and is disastrous for status = 'NEW'. Oracle mitigates this
with bind peeking and adaptive cursor sharing, but if you have one genuinely skewed predicate, a
literal there is a defensible choice.
Maintenance
-- Is anything using this index? Turn it invisible before you drop it.
ALTER INDEX orders_status_ix INVISIBLE; -- optimiser ignores it, still maintained
ALTER INDEX orders_status_ix VISIBLE; -- instant rollback if things get slower
DROP INDEX orders_status_ix;
-- Rebuild without blocking DML
ALTER INDEX orders_customer_ix REBUILD ONLINE;
-- Indexes on a table, and their selectivity
SELECT index_name, uniqueness, distinct_keys, num_rows, last_analyzed
FROM user_indexes WHERE table_name = 'ORDERS';
SELECT index_name, column_position, column_name
FROM user_ind_columns WHERE table_name = 'ORDERS'
ORDER BY index_name, column_position;INVISIBLE is the right way to retire an index: you get the performance effect of
dropping it with a one-statement undo, and you never pay to rebuild a 40 GB index you dropped by
mistake.
Remember indexes are not free. Every one of them is extra work on every
INSERT, UPDATE of its columns, and DELETE. A table with twelve
indexes has a slow write path, and some of those twelve are redundant prefixes of the others.
A short checklist
- Get the real plan with
GATHER_PLAN_STATISTICSandALLSTATS LAST. - Find the first line where
E-RowsandA-Rowsdiverge badly. That is the problem. - Check the statistics on the objects involved before touching anything else.
- Look for functions and implicit conversions wrapped around indexed columns.
- Check that every foreign key you join on has an index.
- Only then consider a new index — and check
USER_IND_COLUMNSfirst, because it may already exist with the columns in the wrong order.
Next
Transactions, isolation and locking — including the reason readers never block writers in Oracle,
and the SKIP LOCKED trick that turns a table into a work queue.