An index exists on the column, yet the execution plan shows TABLE ACCESS FULL. Usually the optimizer is right: the way the query is written makes the index unusable, or reading the rows through the index would cost more than scanning the table. The plan itself tells you which, through its predicates and row estimates. This guide reproduces the six most common causes on one table, shows the plan for each, and the change that lets the index work, or the reason it should not.
Sample Table
50,000 customers with an index on EMAIL, PHONE, STATUS, REFERRER_ID, and a composite index on LAST_NAME and FIRST_NAME:
Example:
create table demo_customers (
customer_id number constraint demo_customers_pk primary key,
email varchar2(60) not null,
phone varchar2(15) not null,
last_name varchar2(30) not null,
first_name varchar2(30) not null,
status varchar2(10) not null,
referrer_id number);
insert into demo_customers
select level,
'user' || level || '@' || decode(mod(level, 2), 0, 'example.com', 'example.org'),
to_char(5550000000 + level),
'Name' || mod(level, 2000),
'First' || mod(level, 997),
case when mod(level, 10) = 0 then 'CLOSED' else 'ACTIVE' end,
case when mod(level, 5000) = 0 then null else mod(level, 500) + 1 end
from dual connect by level <= 50000;
commit;
create index demo_cust_email_ix on demo_customers (email);
create index demo_cust_phone_ix on demo_customers (phone);
create index demo_cust_name_ix on demo_customers (last_name, first_name);
create index demo_cust_status_ix on demo_customers (status);
create index demo_cust_referrer_ix on demo_customers (referrer_id);
exec dbms_stats.gather_table_stats(user, 'DEMO_CUSTOMERS')Output:
Table DEMO_CUSTOMERS created. 50,000 rows inserted. Commit complete. Index DEMO_CUST_EMAIL_IX created. Index DEMO_CUST_PHONE_IX created. Index DEMO_CUST_NAME_IX created. Index DEMO_CUST_STATUS_IX created. Index DEMO_CUST_REFERRER_IX created. PL/SQL procedure successfully completed.
1. A Function on the Indexed Column
Example:
explain plan for select * from demo_customers where upper(email) = 'USER42@EXAMPLE.COM'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
____________________________________________________________________
Plan hash value: 811847660
-----------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------
| 0 | SELECT STATEMENT | | 500 | 136 (0)|
|* 1 | TABLE ACCESS FULL| DEMO_CUSTOMERS | 500 | 136 (0)|
-----------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter(UPPER("EMAIL")='USER42@EXAMPLE.COM')
13 rows selected.The index holds EMAIL values, but the query asks for UPPER(EMAIL), which the index does not contain. Oracle has to compute UPPER for every row, so it scans the table. A function-based index stores the computed values:
Example:
create index demo_cust_email_upper_ix on demo_customers (upper(email)); explain plan for select * from demo_customers where upper(email) = 'USER42@EXAMPLE.COM'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Index DEMO_CUST_EMAIL_UPPER_IX created.
Explained.
PLAN_TABLE_OUTPUT
________________________________________________________________________________________________
Plan hash value: 4098601935
---------------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
---------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 2 (0)|
| 1 | TABLE ACCESS BY INDEX ROWID BATCHED| DEMO_CUSTOMERS | 1 | 2 (0)|
|* 2 | INDEX RANGE SCAN | DEMO_CUST_EMAIL_UPPER_IX | 1 | 1 (0)|
---------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access(UPPER("EMAIL")='USER42@EXAMPLE.COM')
14 rows selected.The cost falls from 136 to 2. The same applies to TRUNC on dates, SUBSTR, arithmetic such as amount * 1.1, and any other expression on the column.
2. Implicit Data Type Conversion
PHONE is a VARCHAR2 column, compared here with a number:
Example:
explain plan for select * from demo_customers where phone = 5550000042; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
____________________________________________________________________
Plan hash value: 811847660
-----------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 136 (0)|
|* 1 | TABLE ACCESS FULL| DEMO_CUSTOMERS | 1 | 136 (0)|
-----------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter(TO_NUMBER("PHONE")=5550000042)
13 rows selected.The predicate shows the hidden function: TO_NUMBER("PHONE"). When a string column is compared with a number, Oracle converts the column, not the number, and a converted column cannot use its index. Compare with a value of the column's own type:
Example:
explain plan for select * from demo_customers where phone = '5550000042'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
__________________________________________________________________________________________
Plan hash value: 892898470
---------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
---------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 2 (0)|
| 1 | TABLE ACCESS BY INDEX ROWID BATCHED| DEMO_CUSTOMERS | 1 | 2 (0)|
|* 2 | INDEX RANGE SCAN | DEMO_CUST_PHONE_IX | 1 | 1 (0)|
---------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("PHONE"='5550000042')
14 rows selected.The same happens with bind variables of the wrong type from an application, which is why it often goes unnoticed: check the predicates for TO_NUMBER, TO_CHAR, or INTERNAL_FUNCTION.
3. LIKE with a Leading Wildcard
Example:
explain plan for select * from demo_customers where email like '%42@example.com'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE'); explain plan for select * from demo_customers where email like 'user42@%'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
____________________________________________________________________
Plan hash value: 811847660
-----------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------
| 0 | SELECT STATEMENT | | 2500 | 136 (0)|
|* 1 | TABLE ACCESS FULL| DEMO_CUSTOMERS | 2500 | 136 (0)|
-----------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("EMAIL" LIKE '%42@example.com')
13 rows selected.
Explained.
PLAN_TABLE_OUTPUT
__________________________________________________________________________________________
Plan hash value: 408745217
---------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
---------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 3 (0)|
| 1 | TABLE ACCESS BY INDEX ROWID BATCHED| DEMO_CUSTOMERS | 1 | 3 (0)|
|* 2 | INDEX RANGE SCAN | DEMO_CUST_EMAIL_IX | 1 | 2 (0)|
---------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("EMAIL" LIKE 'user42@%')
filter("EMAIL" LIKE 'user42@%')
15 rows selected.An index is sorted from the first character on, so it can find values that start with 'user42@', but not values that end with something. A pattern that starts with % needs a full scan. For searches inside text, Oracle Text indexes are the right tool.
4. The Leading Column of a Composite Index Is Missing
Example:
explain plan for select * from demo_customers where first_name = 'First42'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE'); explain plan for select * from demo_customers where last_name = 'Name42' and first_name = 'First42'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
____________________________________________________________________
Plan hash value: 811847660
-----------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------
| 0 | SELECT STATEMENT | | 50 | 136 (0)|
|* 1 | TABLE ACCESS FULL| DEMO_CUSTOMERS | 50 | 136 (0)|
-----------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("FIRST_NAME"='First42')
13 rows selected.
Explained.
PLAN_TABLE_OUTPUT
_________________________________________________________________________________________
Plan hash value: 2997405403
--------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
--------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 2 (0)|
| 1 | TABLE ACCESS BY INDEX ROWID BATCHED| DEMO_CUSTOMERS | 1 | 2 (0)|
|* 2 | INDEX RANGE SCAN | DEMO_CUST_NAME_IX | 1 | 1 (0)|
--------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("LAST_NAME"='Name42' AND "FIRST_NAME"='First42')
14 rows selected.DEMO_CUST_NAME_IX is sorted by LAST_NAME first. A search on FIRST_NAME alone would have to look under each of the 2,000 last names; a skip scan does that only when the leading column has few distinct values, so here the optimizer scans the table. With LAST_NAME in the query, the index works. Otherwise, create an index that starts with FIRST_NAME, or change the column order to match the most common queries.
5. The Query Returns Too Many Rows
Example:
explain plan for select * from demo_customers where status = 'ACTIVE'; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
____________________________________________________________________
Plan hash value: 811847660
-----------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------
| 0 | SELECT STATEMENT | | 25000 | 136 (0)|
|* 1 | TABLE ACCESS FULL| DEMO_CUSTOMERS | 25000 | 136 (0)|
-----------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("STATUS"='ACTIVE')
13 rows selected.STATUS has two values, so the optimizer expects half of the table, 25,000 rows; in fact nine in ten customers are ACTIVE. Reading that many rows one by one through the index would cost far more than one full scan, so the index is ignored on purpose. An index on a column with few values helps only for the rare value, and only when the optimizer knows it is rare, which needs a histogram.
6. Searching for NULL
Example:
explain plan for select * from demo_customers where referrer_id is null; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Explained.
PLAN_TABLE_OUTPUT
____________________________________________________________________
Plan hash value: 811847660
-----------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------
| 0 | SELECT STATEMENT | | 10 | 136 (0)|
|* 1 | TABLE ACCESS FULL| DEMO_CUSTOMERS | 10 | 136 (0)|
-----------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("REFERRER_ID" IS NULL)
13 rows selected.Only 10 customers have no referrer, and the optimizer knows it, yet it scans the table. A B-tree index does not store entries whose indexed columns are all NULL, so DEMO_CUST_REFERRER_IX has no entry for these rows. Adding a constant as a second column makes every row appear in the index:
Example:
create index demo_cust_referrer_null_ix on demo_customers (referrer_id, 0); explain plan for select * from demo_customers where referrer_id is null; select * from dbms_xplan.display(format => 'BASIC +ROWS +COST +PREDICATE');
Output:
Index DEMO_CUST_REFERRER_NULL_IX created.
Explained.
PLAN_TABLE_OUTPUT
__________________________________________________________________________________________________
Plan hash value: 316965343
-----------------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost (%CPU)|
-----------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 10 | 12 (0)|
| 1 | TABLE ACCESS BY INDEX ROWID BATCHED| DEMO_CUSTOMERS | 10 | 12 (0)|
|* 2 | INDEX RANGE SCAN | DEMO_CUST_REFERRER_NULL_IX | 10 | 2 (0)|
-----------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("REFERRER_ID" IS NULL)
14 rows selected.The entry (NULL, 0) is not all NULL, so it is indexed, and IS NULL becomes an access predicate with a cost of 12 instead of 136.
Things to Know
- Read the Predicate Information first: a filter on the indexed column, or a function wrapped around it, explains most cases.
- Check the estimated rows: when they are a large share of the table, the full scan is the better plan, and forcing the index makes things worse.
- Missing or stale statistics lead to wrong estimates and wrong choices; gather them before blaming the index.
- An index made invisible is ignored by the optimizer unless the parameter OPTIMIZER_USE_INVISIBLE_INDEXES is TRUE.
Related Guides
- How to Create Function-Based Indexes in Oracle
- How to Read Table and Index Access Paths in Oracle Execution Plans
Conclusion
Oracle skips an index when the query hides the column inside a function or a type conversion, starts a LIKE pattern with %, omits the leading column of a composite index, looks for NULL in a single-column index, or returns too many rows. The plan's predicates and row estimates show which case you have; fix the query or the index for the first four, and accept the full scan for the last.
