The Magical NVL Function




A while ago, I came across an interesting NVL trick on Connor Mc'Donald's blog. In his example, he compares a query that uses an optional parameter :p

select *
from tab
where ( X = :P or :P is null)

 with a similar approach using the NVL function.

select *
from tab
where X = NVL(:P,X)

NOTE: The two queries above behave the same only if column X is NOT NULL.

Both queries take an optional parameter :p. If :p has a value, only the rows where X = :p will be returned.
If :p is left as NULL, all rows in the table will come back (because WHERE X = X).

I found this example intriguing enough to explore it myself. To make things even more interesting, I’ll also include a comparison using the COALESCE function.


DEMO DB:
Schemat: HR
Wersja:Oracle Database 18c Express Edition Release 18.0.0.0.0 - Production

QUERY USING A LOGICAL "OR"

This query looks for an employee in the EMPLOYEES table based on the :nEmpId parameter.
If :nEmpId is NULL, it will simply return all rows from the table.

select count(*), 
       sum(salary) 
from employees
where employee_id = :nEmpId 
       or :nEmpId is null;

--------------------------------------------------------------------------------
| Id  | Operation          | Name      | Rows  | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |           |     1 |     8 |     3   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |           |     1 |     8 |            |          |
|*  2 |   TABLE ACCESS FULL| EMPLOYEES |     6 |    48 |     3   (0)| 00:00:01 |
--------------------------------------------------------------------------------

Looking at the execution plan, it’s clear that whether we use a specific value or NULL in the condition, the query still ends up doing a FULL TABLE SCAN — even though the EMPLOYEE_ID column has an index and is the primary key.

QUERY USING COALESCE

We can handle an optional parameter more elegantly by using the COALESCE function.

select count(*), 
       sum(salary) 
from employees
where employee_id = 
         coalesce(to_number(:nEmpId), 
            employee_id);

--------------------------------------------------------------------------------
| Id  | Operation          | Name      | Rows  | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |           |     1 |     8 |     3   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE    |           |     1 |     8 |            |          |
|*  2 |   TABLE ACCESS FULL| EMPLOYEES |     1 |     8 |     3   (0)| 00:00:01 |
--------------------------------------------------------------------------------

The execution plan is exactly the same as with the query using a logical OR. Here too, the query ends up fetching data via a FULL TABLE SCAN.

QUERY USING NVL

Now it’s time for the magical trick! In this query, we’ll swap out COALESCE for the NVL function. 

select count(*), 
       sum(salary) 
from employees
where employee_id = 
         nvl(:nEmpId, 
            employee_id);
---------------------------------------------------------------------------------------------------
| Id  | Operation                       | Name            | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                |                 |     1 |    13 |     4   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE                 |                 |     1 |    13 |            |          |
|   2 |   VIEW                          | VW_ORE_B4851255 |   108 |  1404 |     4   (0)| 00:00:01 |
|   3 |    UNION-ALL                    |                 |       |       |            |          |
|*  4 |     FILTER                      |                 |       |       |            |          |
|   5 |      TABLE ACCESS BY INDEX ROWID| EMPLOYEES       |     1 |     8 |     1   (0)| 00:00:01 |
|*  6 |       INDEX UNIQUE SCAN         | EMP_EMP_ID_PK   |     1 |       |     0   (0)| 00:00:01 |
|*  7 |     FILTER                      |                 |       |       |            |          |
|   8 |      TABLE ACCESS FULL          | EMPLOYEES       |   107 |   856 |     3   (0)| 00:00:01 |
---------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   4 - filter(:NEMPID IS NOT NULL)
   6 - access("EMPLOYEE_ID"=:NEMPID)
   7 - filter(:NEMPID IS NULL)

Even though the NVL query is functionally identical to the previous two, the execution plan suddenly changes! Instead of a single FULL TABLE SCAN, we now see two data access methods:

  • FULL TABLE SCAN – operation #8

  • INDEX UNIQUE SCAN – operation #6

Wait… two access methods? But we’re only querying one table, EMPLOYEES!

Here’s the magic: NVL is clever enough that if the column has an index and we pass a value as a parameter, the optimizer decides to use the index to fetch the data.

Statistics
-----------------------------------------------------------
               3  Requests to/from client
               2  consistent gets
               2  consistent gets examination
               2  consistent gets examination (fastpath)
               2  consistent gets from cache

If we leave the :nEmpId parameter empty, Oracle understands that we want to fetch all rows from the table — and in this case, it goes with a FULL TABLE SCAN.

Statistics
-----------------------------------------------------------
               4  Requests to/from client
               6  consistent gets
               6  consistent gets from cache
               6  consistent gets pin
               6  consistent gets pin (fastpath)

So, depending on whether we provide a value for the parameter or leave it empty, we get a completely different execution plan!

Pretty clever 🙂

But what happens if we switch to a newer database version?

Oracle Database 23ai Free Release 23.0.0.0.0 - Develop, Learn, and Run for Free

Queries with NVL and COALESCE remain unchanged.


COALESCE still rigidly scans the entire table, regardless of the parameter value, while NVL continues to show off its magical trick.

But what about the query with a logical OR condition?

select count(*), 
       sum(salary) 
from employees
where employee_id = :nEmpId 
       or :nEmpId is null;
---------------------------------------------------------------------------------------------------
| Id  | Operation                       | Name            | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                |                 |     1 |    13 |     4   (0)| 00:00:01 |
|   1 |  SORT AGGREGATE                 |                 |     1 |    13 |            |          |
|   2 |   VIEW                          | VW_ORE_B4851255 |   108 |  1404 |     4   (0)| 00:00:01 |
|   3 |    UNION-ALL                    |                 |   108 |   436 |     4   (0)| 00:00:01 |
|*  4 |     FILTER                      |                 |       |       |            |          |
|   5 |      TABLE ACCESS FULL          | EMPLOYEES       |   107 |   428 |     3   (0)| 00:00:01 |
|*  6 |     FILTER                      |                 |       |       |            |          |
|   7 |      TABLE ACCESS BY INDEX ROWID| EMPLOYEES       |     1 |     8 |     1   (0)| 00:00:01 |
|*  8 |       INDEX UNIQUE SCAN         | EMP_EMP_ID_PK   |     1 |       |     0   (0)| 00:00:01 |
---------------------------------------------------------------------------------------------------

In the latest 23ai version, the query with OR has finally caught up with NVL. Just like NVL, depending on the value of the :nEmpId parameter, it will either use the INDEX to fetch data (operation #8) or fall back to a FULL TABLE SCAN (operation #5).

COALESCE, however, still hasn’t learned anything — it stubbornly scans every block, full table scan style.

Komentarze