xb.sql and xbi.sql - Explain Oracle Execution Plans Better! (Part 3 - Cardinality Misestimates)

2025-11-03

Here’s part 3 of my xb.sql & xbi.sql article series that I started writing back in 2019 :-)

The importance of row-count (cardinality) estimates

When I look at complex enough SQLs and optimizer misbehavior, the optimizer cardinality estimates (E-Rows) are the most important optimizer numbers to follow in the execution plan, for understanding the optimizer choices. Of course for the actual execution metrics of the newly optimized plan, we’d look into A-Time, A-Rows, Starts, LIOs and PIOs as I’ve shown in previous posts. In this post, I’ll show how I walk through plans with bad cardinality misestimates and drill down into where those misestimates originate from.

This is a slide from my Oracle SQL tuning course that shows a conceptual diagram of the components and inputs involved in estimating a good plan for given optimizer inputs (hint: I’m running a Black Friday sale already, plus a few new special-focus Oracle Deep Dive seminars):

Oracle optimizer inputs overview

The diagram above shows the components and inputs involved, not the exact order of steps. The order depends on how the optimizer iterates through its search space (that can be seen in the CBO trace).

Close enough cardinality estimates are essential for any non-trivial SQL. After all, when the optimizer estimates that a driving table or a more complex row-source returns only 10 rows after its filtering, but the actual real rowcount coming from that row-source is 100k, you may end up doing some nested loop join lookup (+ index range scan) orders of magnitude more times than the optimizer planned and optimized for!

Both heavy overestimates and underestimates of row-counts can cause problems. The overestimates may drive you to full table scans instead of index usage and underestimates may leave you with excessive LIOs driven by nested loops or FILTER loops. Usually it’s a problem when the estimates are off by order(s) of magnitude, but this also depends on how the rest of the optimizer inputs look like. For example, if you end up with nested loops, but have great indexes that can handle even 1M nested loop join lookups efficiently, the query may still be running fast enough so that nobody complains :-)

Query examples with xb.sql showing cardinality misestimate factors

Here’s an example of a demo query I use for showing some of the complexities the optimizer may endure:

SELECT /*+ GATHER_PLAN_STATISTICS */
    c.customer_id
  , c.account_mgr_id
  , c.credit_limit
  , o.order_total 
FROM 
    soe.customers c
  , soe.orders o 
WHERE
-- join conditions
    o.customer_id = c.customer_id 
AND o.order_total > 2 * c.credit_limit 
-- filter conditions
AND c.account_mgr_id IN (73, 75, 54)
AND (c.customer_since < TO_DATE('2010-01-01', 'YYYY-MM-DD') OR c.customer_class = 'Business')

Here’s the usual execution plan, showing that the optimizer converted the IN (73, 75, 54) condition above into an INLIST ITERATOR loop (line #3) that just does a separate index range scan under it for each value in its inlist. Since we had 3 values in the inlist, the Starts = 3 for line #4 under that inlist iterator.

SQL_ID  7muc7uq7nx6ru, child number 0

----------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                              | Name                    | Starts | E-Rows | A-Rows | Buffers | Cost (%CPU)|
----------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                       |                         |      1 |        |    136 |    3900 |  1353K(100)|
|   1 |  NESTED LOOPS                          |                         |      1 |  50912 |    136 |    3900 |  1353K  (1)|
|   2 |   NESTED LOOPS                         |                         |      1 |    622K|   1506 |    2394 |  1353K  (1)|
|   3 |    INLIST ITERATOR                     |                         |      1 |        |    580 |     643 |            |
|*  4 |     TABLE ACCESS BY INDEX ROWID BATCHED| CUSTOMERS               |      3 |    207K|    580 |     643 |   253K  (1)|
|*  5 |      INDEX RANGE SCAN                  | CUST_ACCOUNT_MANAGER_IX |      3 |    297K|    630 |      13 |   641   (1)|
|*  6 |    INDEX RANGE SCAN                    | ORD_CUSTOMER_IX         |    580 |      3 |   1506 |    1751 |     3   (0)|
|*  7 |   TABLE ACCESS BY INDEX ROWID          | ORDERS                  |   1506 |      1 |    136 |    1506 |     6   (0)|
----------------------------------------------------------------------------------------------------------------------------

   4 - filter(("C"."CUSTOMER_SINCE"<TO_DATE(' 2010-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss') OR "C"."CUSTOMER_CLASS"='Business'))
   5 - access(("C"."ACCOUNT_MGR_ID"=54 OR "C"."ACCOUNT_MGR_ID"=73 OR "C"."ACCOUNT_MGR_ID"=75))
   6 - access("O"."CUSTOMER_ID"="C"."CUSTOMER_ID")
   7 - filter("O"."ORDER_TOTAL">2*"C"."CREDIT_LIMIT")

The E-Rows vs A-Rows values above show a pretty big discrepancy, but the units of these columns are not the same! The E-Rows is the optimizer’s cardinality estimate per Start and A-Rows is the total number of rows (returned to parent by this rowsource) across all Starts. As long as the Starts = 1 for a rowsource, you can compare E-Rows and A-Rows directly. But if you look into the last plan line #7, the E-Rows = 1, A-Rows = 1506, but that actually adds up due to the unit difference: Starts * E-Rows = A-Rows, so 1506 * 1 = 1506, that estimate turned out to be correct, when comparing to actual execution stats.

This can get more complicated with bigger plans and larger Starts numbers (looping in the plan) and that’s why I added this simple math to my xb.sql/xbi.sql scripts, with a basic “Optimizer cardinality misestimate factor” calculated field.

I took the SQL_ID of the previous plan and ran xbi.sql (eXplain Better by SQL_ID). I made the output much narrower to focus only on the cardinality and row-counts:

SQL> @xbi 7muc7uq7nx6ru 0
-- xbi.sql: eXplain Better v1.01 for sql_id=7muc7uq7nx6ru child=0 - by Tanel Poder (https://tanelpoder.com)

 Pred    Op                                                       Rowsource  Real #rows  Est. rows   Opt. Card. 
 #Col    ID Row Source                                               starts    returned      total  misestimate
----- ----- ---------------------------------------------------- ---------- ----------- ---------- ------------ 
          0 SELECT STATEMENT                                              1         136                         
          1  NESTED LOOPS                                                 1         136      50912         374x 
          2   NESTED LOOPS                                                1        1506     622869         414x 
          3    INLIST ITERATOR                                            1         580                         
    F     4     TABLE ACCESS BY INDEX ROWID BATCHED [CUSTOMERS]           3         580     622869        1074x 
  A#1     5      INDEX RANGE SCAN [CUST_ACCOUNT_MANAGER_IX]               3         630     891405        1415x
  A#1     6    INDEX RANGE SCAN [ORD_CUSTOMER_IX]                       580        1506       1740           1x 
    F     7   TABLE ACCESS BY INDEX ROWID [ORDERS]                     1506         136       1506          11x 

   Op 
   ID   Predicate Information (identified by operation id):
------------------------------------------------------------------------------------------------------------
    4 - filter(("C"."CUSTOMER_SINCE"<TO_DATE(' 2010-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss') OR
        "C"."CUSTOMER_CLASS"='Business'))
    5 - access(("C"."ACCOUNT_MGR_ID"=54 OR "C"."ACCOUNT_MGR_ID"=73 OR "C"."ACCOUNT_MGR_ID"=75))
    6 - access("O"."CUSTOMER_ID"="C"."CUSTOMER_ID")
    7 - filter("O"."ORDER_TOTAL">2*"C"."CREDIT_LIMIT")

Now it is a little bit easier to navigate to the row source(s) where the initial cardinality misestimate originates from. I walked the plan to the “deepest” rowsource in the plan tree (#5, INDEX RANGE SCAN with cardinality overestimate 1415x). If you walk back upwards to its parent and grandparent operations (1074x, 414x, 374x overestimate) - these misestimates are likely “victims” of the original cardinality misestimate deeper down.

So the next step here would be to see what the plan line #5 is doing then, what filter/access predicates it has (see #5 above). Are we filtering using some (implicit datatype conversion) functions or CASE expressions that the optimizer can’t reason about - and what kind of stats does this specific column have on it.

In my case, the predicates are OK, just 3 inlist-iterations with exact equality searches comparing numbers to numbers with an index, but the heavy cardinality overestimate comes from how the hybrid histograms work with the current shape of the data in the filtered column. I recently blogged about it.

My actual problem that led me to finally write this article happened when I ran the same query with 5 elements in that INLIST:

AND c.account_mgr_id IN (73, 75, 54, 42, 45)

Pretty much the same query, right, but it ran much slower and gave a different plan. I ran just @xb this time, to report the previous query execution plan in the current session (without having to specify the SQL_ID):

SQL> @xb
-- xb.sql: eXplain Better v1.01 for prev SQL in the current session - by Tanel Poder (https://tanelpoder.com)

 Pred    Op                                                      Rowsource  Real #rows     Est. rows  Opt. Card.
 #Col    ID Row Source                                              starts    returned         total misestimate
----- ----- --------------------------------------------------- ---------- ----------- ------------- -----------
          0 SELECT STATEMENT                                             1         153                          
   FA     1  HASH JOIN                                                   1         153         84853        555x
          2   INLIST ITERATOR                                            1         690                          
    F     3    TABLE ACCESS BY INDEX ROWID BATCHED [CUSTOMERS]           5         690       1730195       2508x
  A#1     4     INDEX RANGE SCAN [CUST_ACCOUNT_MANAGER_IX]               5         780       2476130       3175x
          5   TABLE ACCESS FULL [ORDERS]                                 1   272101067     272101067          1x

   Op 
   ID   Predicate Information (identified by operation id):
------------------------------------------------------------------------------------------------------------
    1 - access("O"."CUSTOMER_ID"="C"."CUSTOMER_ID")
    1 - filter("O"."ORDER_TOTAL">2*"C"."CREDIT_LIMIT")
    3 - filter(("C"."CUSTOMER_SINCE"<TO_DATE(' 2010-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss') OR
        "C"."CUSTOMER_CLASS"='Business'))
    4 - access(("C"."ACCOUNT_MGR_ID"=42 OR "C"."ACCOUNT_MGR_ID"=45 OR "C"."ACCOUNT_MGR_ID"=54 OR
        "C"."ACCOUNT_MGR_ID"=73 OR "C"."ACCOUNT_MGR_ID"=75))

The optimizer has chosen to do a full table scan with a hash join this time, because its estimated Cost for doing 5 INLIST ITERATOR index range scans (plus related table block visits) ended up bigger than just doing a full table scan with a hash join.

Since it’s the cardinality that greatly affects optimizer cost calculations in complex plans, we shouldn’t be wondering about optimizer cost here (optimizer’s output), but cardinality calculations based on optimizers inputs. So we’d be asking the question how to give Oracle better cardinality estimates here. And that’s why we have so many optimizer cardinality adjustment related features these days (SQL profiles, statistics feedback, adaptive query execution features and some stats gathering functionality too).

I sometimes use the CARDINALITY hint when in troubleshooting mode, to see whether the optimizer would automatically do the right thing, if it somehow were able to estimate the row-counts correctly.

There’s plenty of more to say about this topic, but it’s also time to go to sleep, so I’ll leave you with two more screenshots to interpret yourself (the first is an overestimate, the second is an underestimate):

Cardinality overestimate

Start from the deepest misestimate and walk up from there:

cardinality overestimate

Cardinality underestimate

In this case the deepest parts of the plan had good estimates, but something went wrong at plan line #4 - WINDOW SORT PUSHED RANK. This can happen due to optimizer bugs too, including estimating Cardinality = 1 for large row-sources!

cardinality underestimate

Thanks for reading, I will go deeper into all this stuff at my upcoming Oracle performance special-focus deep dives, the first one starting this Wednesday, November 5th ;-)


  1. Updated video course material to be announced soon:
    Linux Performance & Troubleshooting training, Advanced Oracle SQL Tuning training, Advanced Oracle Troubleshooting training.
    Check the current versions out here! ^^^
  2. Get randomly timed updates by email or follow Social/RSS