Oracle Single-Column Index AccessHow Oracle decides between unique scan, range scan, full table scan, full index scan, and fast full index scan
Single-column indexes look simple, but the execution plans they trigger are shaped by selectivity, row ordering, rowid lookup cost, requested columns, and whether the optimizer can get the answer from the index alone. This guide turns that apparently small topic into a practical mental model you can use when reading plans and designing indexes.
The real question is not “is there an index?” but “what is the cheapest way to get the rows?”
Oracle does not reward a query just because an index exists. The optimizer compares access paths and chooses the one with the lowest estimated work. With a single-column B-tree index, that work may involve probing the index, collecting rowids, visiting table blocks, and optionally sorting. In other cases a full table scan or a full index scan is cheaper because it touches storage more efficiently.
A healthy DBA instinct is to separate index lookup cost from table row retrieval cost. Many plans look index-friendly at first glance, but the expensive part is actually the flood of table block visits that comes after the index returns rowids.
What an index helps with
What it does not guarantee
What the optimizer weighs
Index unique scan versus index range scan
An INDEX UNIQUE SCAN is the tightest B-tree access path. Oracle uses it when an equality predicate can identify at most one entry in a unique index. An INDEX RANGE SCAN is broader: it walks a portion of the index in key order and returns one or more rowids. Equality predicates on non-unique indexes, range predicates, prefix LIKE predicates, and partial key matches naturally fit the range-scan family.
Unique scan in practice
- Equality on a unique or primary-key-backed index
- At most one rowid expected
- Usually followed by
TABLE ACCESS BY INDEX ROWIDunless the index alone satisfies the query
Range scan in practice
- Equality on a non-unique column
BETWEEN,>,<, and prefixLIKE 'A%'- Ordered retrieval of matching index entries
A query can target a logical primary key and still show INDEX RANGE SCAN if the underlying index is non-unique. Do not over-interpret the word “range” as automatically meaning “many rows.”
EXPLAIN PLAN FOR SELECT * FROM access_test WHERE id = 12345; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); EXPLAIN PLAN FOR SELECT * FROM access_test WHERE id BETWEEN 1000 AND 1200; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); EXPLAIN PLAN FOR SELECT * FROM access_test WHERE region LIKE 'N%'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
| Predicate shape | Likely index access | Why |
|---|---|---|
id = 12345 | INDEX UNIQUE SCAN if the index is unique | The key can identify at most one entry. |
status = 'ACTIVE' | INDEX RANGE SCAN or full table scan | Many rows may match, so rowid cost matters. |
id BETWEEN 1 AND 500 | INDEX RANGE SCAN | The optimizer can walk a start key to a stop key. |
region LIKE 'NO%' | INDEX RANGE SCAN | Prefix matching preserves a navigable key range. |
Why Oracle may choose a full table scan even when the index is perfectly valid
A full table scan reads every formatted block below the high water mark, and Oracle can read those blocks efficiently with multiblock I/O. That can beat an index-driven path when the query returns a large fraction of the table, when the table is small, when parallelism makes the scan attractive, or when fetching rows by rowid would bounce across many table blocks.
This is why folklore like “Oracle stops using the index after 10%” is not reliable. The optimizer does not use a universal percentage cutoff. It estimates total work for the available alternatives.
Full scan strengths
Index path strengths
Common beginner mistake
If a plan uses TABLE ACCESS FULL, your first question should be “how many rows and blocks did Oracle expect to touch?” not “why is Oracle ignoring my index?”
EXPLAIN PLAN FOR SELECT * FROM access_test WHERE status = 'INACTIVE'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); EXPLAIN PLAN FOR SELECT * FROM access_test WHERE status = 'ACTIVE'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
Clustering factor explains why two equally selective indexes can behave very differently
The clustering factor is Oracle’s way of estimating how table rows are physically scattered relative to the index key order. If neighboring index entries tend to point to rows stored in the same or nearby table blocks, the clustering factor is low and index-driven table access is attractive. If adjacent index entries point all over the table, the clustering factor is high and Oracle estimates many table block visits.
Think of it as a measure of rowid locality. A good clustering factor does not mean the index is “more selective”; it means the trip from index entry to table row is cheaper because the rowids are physically less chaotic.
SELECT i.index_name,
i.num_rows,
i.clustering_factor,
t.blocks,
t.num_rows AS table_rows
FROM user_indexes i
JOIN user_tables t
ON t.table_name = i.table_name
WHERE i.table_name = 'ACCESS_TEST'
ORDER BY i.index_name;When the clustering factor is closer to the number of table blocks, index-driven row retrieval is usually more locality-friendly. When it drifts closer to the number of rows, Oracle expects more scattered table access.
Index full scan versus index fast full scan: same object, different behavior
These two plan operations are commonly confused because both can read a large part of the index. They are not interchangeable.
INDEX FULL SCAN
- Reads the index in key order
- Can support ordering requirements
- Useful when Oracle wants the index order itself
INDEX FAST FULL SCAN
- Reads index blocks as a skinny structure
- Does not preserve sorted key order
- Often attractive for index-only aggregation or broad filtering
An easy mental model is this: a full scan walks the index as an ordered tree; a fast full scan reads the index as a compact segment. If Oracle needs sorted keys, plain full scan remains relevant. If it only needs the indexed columns and wants fast broad access, fast full scan may win.
EXPLAIN PLAN FOR SELECT id FROM access_test ORDER BY id; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); EXPLAIN PLAN FOR SELECT COUNT(*) FROM access_test WHERE id IS NOT NULL; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
INDEX FULL SCAN does not mean “bad plan.” It means Oracle decided that scanning the index in order was cheaper or more useful than another path.
Covering access and sort elimination: when a single-column index does more than filtering
A single-column index becomes especially valuable when the query can be answered from the index alone or when the requested order matches the index order. In those cases Oracle may avoid table access, sorting, or both. This is one reason execution-plan reading should focus on the whole row source tree, not just the first access operation.
Covering effect
Ordering effect
Trade-off
EXPLAIN PLAN FOR SELECT id FROM access_test WHERE id BETWEEN 10000 AND 10100 ORDER BY id; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); EXPLAIN PLAN FOR SELECT COUNT(status) FROM access_test WHERE status = 'ACTIVE'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
If you see TABLE ACCESS BY INDEX ROWID, the query still needed table data after probing the index. If that table access disappears, the index is acting like a smaller, cheaper structure for that particular statement.
How to read real plans without fooling yourself
Execution-plan names are useful, but they are not enough by themselves. Good plan reading combines the access operation, estimated and actual rows, predicates, and whether the table was revisited many times after the index probe. The safest habit is to compare estimated behavior with observed behavior from cursor statistics.
| Plan line | What it means | What to ask next |
|---|---|---|
INDEX UNIQUE SCAN | At most one matching index entry is expected. | Did Oracle still need a table lookup afterward? |
INDEX RANGE SCAN | A navigable subset of the index is read in key order. | How many rowids came back, and how scattered were the table blocks? |
INDEX FULL SCAN | The index is read in order. | Was Oracle using the index for ordering or broad but ordered access? |
INDEX FAST FULL SCAN | The index is scanned as a compact structure without order preservation. | Was the query index-only, such as a count or projection of indexed columns? |
TABLE ACCESS BY INDEX ROWID | Oracle found rowids from the index and then fetched table rows. | Is that rowid phase the real cost center? |
TABLE ACCESS FULL | The table is read directly, usually with multiblock I/O. | Was the result set large enough that this was reasonable? |
ALTER SESSION SET statistics_level = ALL;
SELECT /*+ gather_plan_statistics */ *
FROM access_test
WHERE region = 'NORTH';
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +PREDICATE +COST +BYTES'
)
);On some systems you may see TABLE ACCESS BY INDEX ROWID BATCHED. That means Oracle is grouping rowid-based table fetches more efficiently. It does not change the basic logic; it refines the table-lookup phase.
1. Read the access line
2. Read predicates
3. Read rows
4. Read table visits
End-to-end lab: one table, three indexes, several access paths
This lab is designed for a learner’s sandbox. It does not invent fake benchmark numbers. Instead, it gives you a reproducible setup, tells you what plan shapes to expect, and shows you how to verify the optimizer’s choice with runtime statistics.
Step 1: Build a table with mixed selectivity
DROP TABLE access_test PURGE;
CREATE TABLE access_test (
id NUMBER NOT NULL,
status VARCHAR2(10) NOT NULL,
region VARCHAR2(20) NOT NULL,
amount NUMBER NOT NULL,
created_on DATE NOT NULL
);
INSERT INTO access_test
SELECT level,
CASE
WHEN MOD(level,10) = 0 THEN 'ACTIVE'
ELSE 'INACTIVE'
END,
CASE MOD(level,5)
WHEN 0 THEN 'NORTH'
WHEN 1 THEN 'SOUTH'
WHEN 2 THEN 'EAST'
WHEN 3 THEN 'WEST'
ELSE 'CENTRAL'
END,
ROUND(DBMS_RANDOM.VALUE(100,10000)),
DATE '2025-01-01' + MOD(level,365)
FROM dual
CONNECT BY level <= 100000;
COMMIT;
ALTER TABLE access_test
ADD CONSTRAINT access_test_pk PRIMARY KEY (id);
CREATE INDEX idx_access_status ON access_test(status);
CREATE INDEX idx_access_region ON access_test(region);
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => USER,
tabname => 'ACCESS_TEST',
cascade => TRUE,
method_opt => 'FOR ALL COLUMNS SIZE AUTO'
);
END;
/Step 2: Confirm the object statistics
SELECT COUNT(*) AS total_rows,
SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) AS active_rows,
SUM(CASE WHEN status = 'INACTIVE' THEN 1 ELSE 0 END) AS inactive_rows
FROM access_test;
SELECT region, COUNT(*)
FROM access_test
GROUP BY region
ORDER BY region;You should observe a very selective key column ID, a low-cardinality STATUS column with a 10/90 split, and a five-value REGION column. That mix is excellent for seeing why Oracle uses different access paths.
Step 3: Probe a unique lookup
ALTER SESSION SET statistics_level = ALL;
SELECT /*+ gather_plan_statistics */ *
FROM access_test
WHERE id = 42424;
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +PREDICATE'
)
);What to verify: expect a primary-key-driven access path, typically INDEX UNIQUE SCAN followed by a table rowid access because the query asks for all columns.
Step 4: Probe a range lookup
SELECT /*+ gather_plan_statistics */ id, amount
FROM access_test
WHERE id BETWEEN 42000 AND 42100
ORDER BY id;
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +PREDICATE'
)
);What to verify: expect INDEX RANGE SCAN. Because the requested order matches the key order, Oracle may not need a separate sort operation.
Step 5: Test low selectivity and watch for a table scan
SELECT /*+ gather_plan_statistics */ COUNT(*)
FROM access_test
WHERE status = 'INACTIVE';
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +PREDICATE'
)
);What to verify: depending on your environment, Oracle may prefer a full table scan because the predicate returns most of the table. If it chooses the index, compare logical I/O and actual rows carefully before concluding that the plan is better.
Step 6: See when the index acts like a skinny structure
SELECT /*+ gather_plan_statistics */ COUNT(id)
FROM access_test
WHERE id IS NOT NULL;
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +PREDICATE'
)
);What to verify: this is a classic situation where Oracle may consider an index full or fast full scan, because the query can potentially be satisfied from index entries without fetching the whole table row.
Step 7: Compare plans before forcing hints
SELECT /*+ gather_plan_statistics */ COUNT(*) FROM access_test WHERE region = 'NORTH'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL,NULL,'ALLSTATS LAST +PREDICATE')); SELECT /*+ gather_plan_statistics INDEX(access_test idx_access_region) */ COUNT(*) FROM access_test WHERE region = 'NORTH'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL,NULL,'ALLSTATS LAST +PREDICATE'));
Use hints here only as a teaching tool to compare alternatives. In real tuning, force an index only after proving that the optimizer’s estimate is wrong or that statistics and data distribution information are incomplete.
Design rules that survive real workloads
- Create a single-column index when the application frequently filters by that column and the resulting row set is selective enough to justify rowid lookups.
- Do not expect a low-cardinality column to be helped automatically by a B-tree index; the result-set size and clustering factor often dominate.
- Gather fresh optimizer statistics after meaningful data changes.
- Read the full plan tree. A beautiful index access line may still lead to expensive table fetches.
- Do not rely on folklore thresholds. Verify with actual plans and cursor statistics.
- Avoid wrapping indexed columns in functions unless you intentionally designed a matching function-based index.
Quiz: build plan-reading instincts
Check whether the concepts feel operational, not just familiar
INDEX FULL SCAN. Fast full scan treats the index more like a compact segment and does not preserve sorted key order.SELECT id FROM access_test ORDER BY id. Why might Oracle favor an index-based plan?INDEX RANGE SCAN for equality?
No comments:
Post a Comment