{"id":2778,"date":"2026-08-23T21:59:28","date_gmt":"2026-08-23T19:59:28","guid":{"rendered":"https:\/\/blog.sqlora.com\/en\/?p=2778"},"modified":"2026-08-23T21:59:31","modified_gmt":"2026-08-23T19:59:31","slug":"oracle-26ai-an-even-closer-look-at-join-to-one","status":"publish","type":"post","link":"https:\/\/blog.sqlora.com\/en\/oracle-26ai-an-even-closer-look-at-join-to-one\/","title":{"rendered":"Oracle 26ai: An Even Closer Look at JOIN TO ONE"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Sometimes the most interesting findings start with a simple question. After publishing my <a href=\"https:\/\/blog.sqlora.com\/en\/oracle-26ai-a-closer-look-at-join-to-one\/\" target=\"_blank\" rel=\"noreferrer noopener\">recent post on Oracle 26ai <code>JOIN TO ONE<\/code><\/a>, Iudith Mentzel pointed out something in one of the query transformations that looked perfectly logical &#8211; but turned out not to describe what Oracle actually does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A small test led me one level deeper, from <code>DBMS_UTILITY.EXPAND_SQL_TEXT<\/code> to the CBO trace, and revealed another interesting aspect of <code>JOIN TO ONE<\/code>: the way Oracle handles its implicit <code>LEFT OUTER JOIN<\/code>.<\/p>\n\n\n\n<!--more-->\n\n\n\n<p class=\"wp-block-paragraph\">In the previous post, I used <code>DBMS_UTILITY.EXPAND_SQL_TEXT<\/code> to investigate how Oracle implements the <code>TO ONE<\/code> guarantee. If Oracle cannot rely on a primary or unique key to guarantee that there is at most one matching row, the expanded SQL contains an additional correlated scalar subquery. Since a scalar subquery cannot return more than one row, this provides an elegant way to detect a violation of the <code>TO ONE<\/code> semantics.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">As a reminder, <strong>Listing 1<\/strong> shows the query and the transformation we see when there is no primary or unique key constraint in <code>ENABLE VALIDATE<\/code> state on <code>PROMOTIONS.PROMO_ID<\/code>.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; highlight: [11,12,13]; title: ; notranslate\" title=\"\">\nselect sum(s.amount_sold) \nfrom   sales s\n       join to one (promotions p)\nwhere  p.promo_name like &#039;NO PROMOTION%&#039;;\n\nSELECT SUM (&quot;A1&quot;.&quot;AMOUNT_SOLD_0&quot;)     &quot;SUM(S.AMOUNT_SOLD)&quot;\nFROM   (SELECT &quot;A3&quot;.&quot;AMOUNT_SOLD&quot;     &quot;AMOUNT_SOLD_0&quot;,\n               &quot;A2&quot;.&quot;PROMO_NAME&quot;      &quot;PROMO_NAME_1&quot;\n        FROM   &quot;ONF&quot;.&quot;SALES&quot; &quot;A3&quot;, &quot;ONF&quot;.&quot;PROMOTIONS&quot; &quot;A2&quot;\n        WHERE  &quot;A3&quot;.&quot;PROMO_ID&quot; = &quot;A2&quot;.&quot;PROMO_ID&quot;\n        AND    &#039;P&#039; = (SELECT &#039;P&#039;  &quot;C&quot;\n                      FROM   &quot;ONF&quot;.&quot;PROMOTIONS&quot; &quot;A4&quot;\n                      WHERE  &quot;A3&quot;.&quot;PROMO_ID&quot; = &quot;A4&quot;.&quot;PROMO_ID&quot;)) &quot;A1&quot;\nWHERE  &quot;A1&quot;.&quot;PROMO_NAME_1&quot; LIKE &#039;NO PROMOTION%&#039;;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 1: <code>JOIN TO ONE<\/code> query and SQL returned by <code>EXPAND_SQL_TEXT<\/code><\/em><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">At least, that was the picture we had so far.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One of the comments on the post raised a very good question: if this scalar subquery is added as a filtering predicate, wouldn&#8217;t it also eliminate rows without a match and therefore effectively turn the implicit <code>LEFT OUTER JOIN<\/code> into an <code>INNER JOIN<\/code>?<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"923\" height=\"381\" src=\"https:\/\/blog.sqlora.com\/en\/wp-content\/uploads\/sites\/2\/2026\/08\/Screenshot-2026-08-21-162043.jpg\" alt=\"\" class=\"wp-image-2790\" srcset=\"https:\/\/blog.sqlora.com\/en\/wp-content\/uploads\/sites\/2\/2026\/08\/Screenshot-2026-08-21-162043.jpg 923w, https:\/\/blog.sqlora.com\/en\/wp-content\/uploads\/sites\/2\/2026\/08\/Screenshot-2026-08-21-162043-300x124.jpg 300w, https:\/\/blog.sqlora.com\/en\/wp-content\/uploads\/sites\/2\/2026\/08\/Screenshot-2026-08-21-162043-768x317.jpg 768w, https:\/\/blog.sqlora.com\/en\/wp-content\/uploads\/sites\/2\/2026\/08\/Screenshot-2026-08-21-162043-624x258.jpg 624w\" sizes=\"auto, (max-width: 923px) 100vw, 923px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Looking at the SQL returned by <code>EXPAND_SQL_TEXT<\/code>, that conclusion makes perfect sense. But a simple test proves that Oracle does preserve the outer join semantics. So there must be more going on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s do a very simple test with two tables and without any constraints:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\ndrop table t1 purge;\ndrop table t2 purge;\n\ncreate table t1 (\n    id  number,\n    val varchar2(10)\n);\n\ncreate table t2 (\n    id  number,\n    val varchar2(10)\n);\n\n-- Two rows on the preserved side\ninsert into t1 values (1, &#039;A&#039;);\ninsert into t1 values (2, &#039;B&#039;);\n\n-- ID 1: exactly one match\n-- ID 2: no match\ninsert into t2 values (1, &#039;A&#039;);\n\ncommit;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 2: Minimal test case without constraints<\/em><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the simple <code>LEFT JOIN<\/code> query produces the expected result in Listing 3 (lines 2\u20138). The next query (lines 12\u201318) uses the new <code>JOIN TO ONE<\/code> syntax. Since we don&#8217;t have any FK-PK constraints, we need to provide the <code>ON<\/code> condition explicitly. The default join semantics for <code>JOIN TO ONE<\/code> is <code>LEFT OUTER JOIN<\/code>, so we can omit the explicit join type. Again, the result is exactly what we expect: the row with <code>ID=2<\/code> from the preserved table <code>T1<\/code> remains in the result, while the columns from <code>T2<\/code> are <code>NULL<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next, let&#8217;s call <code>EXPAND_SQL_TEXT<\/code> to see the transformed query. As expected, we find the scalar subquery we are already familiar with (lines 53\u201355). Now let&#8217;s execute this transformed query. It should, of course, return the same result as the original query, right?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Wrong!<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The transformed query behaves like an <code>INNER JOIN<\/code>: the row with <code>ID=2<\/code> is filtered out.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; highlight: [53,54,55]; title: ; notranslate\" title=\"\">\nSQL&gt; -- Query with LEFT JOIN to T2\nSQL&gt; SELECT t1.id, t1.val, t2.val\nFROM   t1 LEFT JOIN t2 ON t1.id = t2.id\n\n        ID VAL        VAL_1     \n---------- ---------- ----------\n         1 A          A         \n         2 B                    \n2 rows selected.\n\nSQL&gt; -- JOIN TO ONE (default is LEFT OUTER JOIN)\nSQL&gt; SELECT t1.id, t1.val, t2.val\nFROM   t1 JOIN TO ONE (t2 ON t1.id = t2.id)\n\n        ID VAL        VAL_1     \n---------- ---------- ----------\n         1 A          A         \n         2 B                    \n2 rows selected.\n\nSQL&gt; -- get the transformation behind it\nSQL&gt; WITH FUNCTION get_sql (p_sql IN CLOB) RETURN CLOB IS\np_sql_out CLOB;\nBEGIN\n  DBMS_UTILITY.EXPAND_SQL_TEXT (p_sql, p_sql_out);\n  RETURN p_sql_out;\nEND;\nSELECT get_sql(q&#039;&#x5B;\nSELECT t1.id, t1.val, t2.val\nFROM   t1 JOIN TO ONE (t2 ON t1.id = t2.id)]&#039;) AS transformation\n\nTRANSFORMATION                                                                  \n--------------------------------------------------------------------------------\nSELECT &quot;A1&quot;.&quot;QCSJ_C000000000300000_0&quot; &quot;ID&quot;,&quot;A1&quot;.&quot;QCSJ_C000000000300002_1&quot; &quot;VAL&quot;,\n&quot;A1&quot;.&quot;QCSJ_C000000000300003_3&quot; &quot;VAL&quot; FROM  (SELECT &quot;A3&quot;.&quot;ID&quot; &quot;QCSJ_C000000000300\n000_0&quot;,&quot;A3&quot;.&quot;VAL&quot; &quot;QCSJ_C000000000300002_1&quot;,&quot;A2&quot;.&quot;ID&quot; &quot;QCSJ_C000000000300001&quot;,&quot;A\n2&quot;.&quot;VAL&quot; &quot;QCSJ_C000000000300003_3&quot; FROM &quot;SH&quot;.&quot;T1&quot; &quot;A3&quot;,&quot;SH&quot;.&quot;T2&quot; &quot;A2&quot; WHERE &quot;A3&quot;\n.&quot;ID&quot;=&quot;A2&quot;.&quot;ID&quot; AND &#039;T2&#039;= (SELECT &#039;T2&#039; &quot;C&quot; FROM &quot;SH&quot;.&quot;T2&quot; &quot;A4&quot; WHERE &quot;A3&quot;.&quot;ID&quot;=&quot;\nA4&quot;.&quot;ID&quot;)) &quot;A1&quot;                                                                 \n                                                                                \n1 row selected.\n\nSQL&gt; -- Execute the transformed query\nSQL&gt; SELECT &quot;A1&quot;.&quot;QCSJ_C000000000300000_0&quot;     &quot;ID&quot;,\n       &quot;A1&quot;.&quot;QCSJ_C000000000300002_1&quot;     &quot;VAL&quot;,\n       &quot;A1&quot;.&quot;QCSJ_C000000000300003_3&quot;     &quot;VAL&quot;\n  FROM (SELECT &quot;A3&quot;.&quot;ID&quot;      &quot;QCSJ_C000000000300000_0&quot;,\n               &quot;A3&quot;.&quot;VAL&quot;     &quot;QCSJ_C000000000300002_1&quot;,\n               &quot;A2&quot;.&quot;ID&quot;      &quot;QCSJ_C000000000300001&quot;,\n               &quot;A2&quot;.&quot;VAL&quot;     &quot;QCSJ_C000000000300003_3&quot;\n          FROM &quot;SH&quot;.&quot;T1&quot; &quot;A3&quot;, &quot;SH&quot;.&quot;T2&quot; &quot;A2&quot;\n         WHERE     &quot;A3&quot;.&quot;ID&quot; = &quot;A2&quot;.&quot;ID&quot;\n               AND &#039;T2&#039; = (SELECT &#039;T2&#039;     &quot;C&quot;\n                             FROM &quot;SH&quot;.&quot;T2&quot; &quot;A4&quot;\n                            WHERE &quot;A3&quot;.&quot;ID&quot; = &quot;A4&quot;.&quot;ID&quot;)) &quot;A1&quot;\n\n        ID VAL        VAL_1     \n---------- ---------- ----------\n         1 A          A         \n1 row selected.\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 3: Different results for the original query and the SQL returned by <code>EXPAND_SQL_TEXT<\/code><\/em><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So we have a contradiction: the original <code>JOIN TO ONE<\/code> query clearly preserves the outer join semantics, while the SQL returned by <code>EXPAND_SQL_TEXT<\/code>, when executed literally, does not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Apparently, <code>EXPAND_SQL_TEXT<\/code> is not showing us the whole story. To find out what Oracle actually does with this query, we need to go one level deeper and look at the optimizer trace.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Going One Level Deeper: The CBO Trace<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For this test, I used a small function I previously showed in <a href=\"https:\/\/blog.sqlora.com\/en\/how-to-get-the-final-sql-after-macro-expansion\/\" target=\"_blank\" rel=\"noreferrer noopener\">this post about SQL macros<\/a> to retrieve the final SQL from the CBO trace. <\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\n...\nFinal query after transformations: qb SEL$FF1EE860 (#1):\n******* UNPARSED QUERY IS *******\n\nSELECT &quot;T1&quot;.&quot;ID&quot;                  &quot;ID&quot;,\n       &quot;T1&quot;.&quot;VAL&quot;                 &quot;VAL&quot;,\n       &quot;VW_LAT_CDDD7452&quot;.&quot;ITEM_2&quot; &quot;VAL&quot;\nFROM   &quot;SH&quot;.&quot;T1&quot; &quot;T1&quot;,\n       LATERAL (\n         (SELECT &quot;T2&quot;.&quot;VAL&quot; &quot;ITEM_2&quot;\n          FROM   &quot;SH&quot;.&quot;T2&quot; &quot;T2&quot;\n          WHERE  &quot;T1&quot;.&quot;ID&quot; = &quot;T2&quot;.&quot;ID&quot;\n          AND    (SELECT &#039;T2&#039; &quot;C&quot;\n                  FROM   &quot;SH&quot;.&quot;T2&quot; &quot;T2&quot;\n                  WHERE  &quot;T2&quot;.&quot;ID&quot; = &quot;T1&quot;.&quot;ID&quot;) = &#039;T2&#039;)\n       )(+) &quot;VW_LAT_CDDD7452&quot;\n\n*************************\n......\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 4: Final query after transformations from the CBO trace<\/em><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And there is the missing piece. The correlated scalar subquery is still there, but it is now inside a <code>LATERAL<\/code> view. More importantly, the entire lateral view is outer-joined to <code>T1<\/code>, as indicated by the <code>(+)<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This preserves exactly the semantics we need. If there is no matching row in <code>T2<\/code>, the lateral view returns no row, but the outer join preserves the row from <code>T1<\/code> and returns <code>NULL<\/code> for the <code>T2<\/code> columns. With one matching row, the join behaves normally. And with more than one matching row, the scalar subquery still enforces the <code>TO ONE<\/code> guarantee.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I used to rely on <code>DBMS_UTILITY.EXPAND_SQL_TEXT<\/code> for looking at query transformations, as many others do. It is a very convenient trick and it can reveal quite a lot about how Oracle rewrites a query.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But we should also remember what <code>EXPAND_SQL_TEXT<\/code> is actually documented for: expanding references to views. Using it to inspect other query transformations is undocumented behavior. And, once again, this is a good reminder of a familiar rule when working with Oracle: <strong>you cannot rely on undocumented features.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Interestingly, within its documented purpose, the documentation also states that the resulting SQL is semantically equivalent to the input query, with a few documented caveats. Our example shows why extending that expectation to other, undocumented transformations is risky. So <code>EXPAND_SQL_TEXT<\/code> remains a useful diagnostic trick &#8211; just not the ultimate source of truth for every query transformation.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">From Transformation to Execution Plan<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Now that we know how Oracle preserves the <code>LEFT OUTER JOIN<\/code> semantics, let&#8217;s go back to the original <code>JOIN TO ONE<\/code> query and look at its execution plan.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Listing 5<\/strong> shows the final transformed query from the CBO trace together with the execution plan of the original <code>JOIN TO ONE<\/code> query. The CBO transformation already suggests that preserving the outer join semantics requires more than the scalar-subquery check alone. Listing 5 shows how this translates into the execution plan. <\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nSELECT &quot;T1&quot;.&quot;ID&quot; &quot;ID&quot;, &quot;T1&quot;.&quot;VAL&quot; &quot;VAL&quot;, &quot;VW_LAT_CDDD7452&quot;.&quot;ITEM_2&quot; &quot;VAL&quot;\n  FROM &quot;SH&quot;.&quot;T1&quot;  &quot;T1&quot;,\n       LATERAL\n           ( (SELECT &quot;T2&quot;.&quot;VAL&quot;     &quot;ITEM_2&quot;\n                FROM &quot;SH&quot;.&quot;T2&quot; &quot;T2&quot;\n               WHERE     &quot;T1&quot;.&quot;ID&quot; = &quot;T2&quot;.&quot;ID&quot;\n                     AND (SELECT &#039;T2&#039;     &quot;C&quot;\n                            FROM &quot;SH&quot;.&quot;T2&quot; &quot;T2&quot;\n                           WHERE &quot;T2&quot;.&quot;ID&quot; = &quot;T1&quot;.&quot;ID&quot;) = &#039;T2&#039;))\n           (+)\n           &quot;VW_LAT_CDDD7452&quot;;\n\n--------------------------------------------------\n| Id  | Operation             | Name            | \n--------------------------------------------------\n|   0 | SELECT STATEMENT      |                 | \n|   1 |  MERGE JOIN OUTER     |                 | \n|   2 |   TABLE ACCESS FULL   | T1              | \n|   3 |   BUFFER SORT         |                 | \n|   4 |    VIEW               | VW_LAT_A18161FF | \n|*  5 |     FILTER            |                 | \n|*  6 |      TABLE ACCESS FULL| T2              | \n|*  7 |      TABLE ACCESS FULL| T2              | \n--------------------------------------------------\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 5: CBO transformation and execution plan for implicit <code>LEFT OUTER JOIN TO ONE<\/code><\/em><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The transformation we saw in the CBO trace explains the more complicated plan. The outer-joined lateral view is clearly visible, together with the <code>MERGE JOIN OUTER<\/code>. It contains both the actual join to <code>T2<\/code> and the correlated scalar subquery. The scalar subquery still has to detect multiple matches, while the surrounding outer-join structure has to preserve unmatched rows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For comparison, with an explicit <code>INNER JOIN TO ONE<\/code>, the final transformed query is much simpler:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; title: ; notranslate\" title=\"\">\nSELECT &quot;T1&quot;.&quot;ID&quot; &quot;ID&quot;, &quot;T1&quot;.&quot;VAL&quot; &quot;VAL&quot;, &quot;T2&quot;.&quot;VAL&quot; &quot;VAL&quot;\n  FROM &quot;SH&quot;.&quot;T1&quot; &quot;T1&quot;, &quot;SH&quot;.&quot;T2&quot; &quot;T2&quot;\n WHERE     &quot;T1&quot;.&quot;ID&quot; = &quot;T2&quot;.&quot;ID&quot;\n       AND (SELECT &#039;T2&#039;     &quot;C&quot;\n              FROM &quot;SH&quot;.&quot;T2&quot; &quot;T2&quot;\n             WHERE &quot;T2&quot;.&quot;ID&quot; = &quot;T1&quot;.&quot;ID&quot;) = &#039;T2&#039;\n\n------------------------------------\n| Id  | Operation           | Name |\n------------------------------------\n|   0 | SELECT STATEMENT    |      |\n|*  1 |  FILTER             |      |\n|*  2 |   HASH JOIN         |      |\n|   3 |    TABLE ACCESS FULL| T2   |\n|   4 |    TABLE ACCESS FULL| T1   |\n|*  5 |   TABLE ACCESS FULL | T2   |\n------------------------------------\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 6: CBO transformation and execution plan for explicit <code>INNER JOIN TO ONE<\/code><\/em><\/strong><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Performance: The Runtime Check Has a Cost<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In my previous post, I discussed the potential performance impact of the additional scalar-subquery check, but didn&#8217;t actually measure it. Also, most of my examples contained a filtering predicate on the joined table, effectively turning the implicit <code>LEFT OUTER JOIN<\/code> into an <code>INNER JOIN<\/code> and hiding another part of the picture. So let&#8217;s now look at both: the actual cost of the runtime check itself, and what changes when the implicit outer join really remains an outer join.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s first look at a more conventional setup with indexes on the dimension keys in Listing 7. The primary key indexes are present, but the corresponding constraints are not in <code>ENABLE VALIDATE<\/code> state, so Oracle still has to enforce the <code>TO ONE<\/code> semantics at runtime. For this first test, I deliberately keep the indexes while preventing Oracle from relying on the corresponding constraints. This lets us isolate the cost of the runtime check when efficient index lookups are available.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; highlight: [14,41,71]; title: ; notranslate\" title=\"\">\nSH&gt;SET AUTOTRACE ON EXPLAIN\nSH&gt;\nSH&gt;select sum(s.amount_sold)\n  2  from   sales s\n  3  join   customers c\n  4    on   c.cust_id = s.cust_id\n  5  join   promotions p\n  6    on   p.promo_id = s.promo_id;\n\nSUM(S.AMOUNT_SOLD)\n------------------\n        98205831.2\n\nElapsed: 00:00:00.25\n\nExecution Plan\n----------------------------------------------------------\nPlan hash value: 441219251\n\n-------------------------------------------------------------------------------------------------------\n| Id  | Operation              | Name         | Rows  | Bytes | Cost (%CPU)| Time     | Pstart| Pstop |\n-------------------------------------------------------------------------------------------------------\n|   0 | SELECT STATEMENT       |              |     1 |    23 |  3866   (1)| 00:00:01 |       |       |\n|   1 |  SORT AGGREGATE        |              |     1 |    23 |            |          |       |       |\n|*  2 |   HASH JOIN            |              |   918K|    20M|  3866   (1)| 00:00:01 |       |       |\n|   3 |    INDEX FAST FULL SCAN| CUSTOMERS_PK | 55500 |   270K|    36   (0)| 00:00:01 |       |       |\n|*  4 |    HASH JOIN           |              |   918K|    15M|  3827   (1)| 00:00:01 |       |       |\n|   5 |     INDEX FULL SCAN    | PROMO_PK     |   503 |  2012 |     1   (0)| 00:00:01 |       |       |\n|   6 |     PARTITION RANGE ALL|              |   918K|    12M|  3824   (1)| 00:00:01 |     1 |    15 |\n|   7 |      TABLE ACCESS FULL | SALES        |   918K|    12M|  3824   (1)| 00:00:01 |     1 |    15 |\n-------------------------------------------------------------------------------------------------------\n\nSH&gt;select sum(s.amount_sold)\n  2  from   sales s\n  3  join to one (INNER JOIN customers c  on c.cust_id = s.cust_id INNER JOIN promotions p  on p.promo_id = s.promo_id );\n\nSUM(S.AMOUNT_SOLD)\n------------------\n        98205831.2\n\nElapsed: 00:00:00.42\n\nExecution Plan\n----------------------------------------------------------\nPlan hash value: 410504316\n\n--------------------------------------------------------------------------------------------------------\n| Id  | Operation               | Name         | Rows  | Bytes | Cost (%CPU)| Time     | Pstart| Pstop |\n--------------------------------------------------------------------------------------------------------\n|   0 | SELECT STATEMENT        |              |     1 |    23 |  5484   (1)| 00:00:01 |       |       |\n|   1 |  SORT AGGREGATE         |              |     1 |    23 |            |          |       |       |\n|*  2 |   FILTER                |              |       |       |            |          |       |       |\n|*  3 |    HASH JOIN            |              |   918K|    20M|  3871   (1)| 00:00:01 |       |       |\n|   4 |     INDEX FAST FULL SCAN| CUSTOMERS_PK | 55500 |   270K|    36   (0)| 00:00:01 |       |       |\n|*  5 |     HASH JOIN           |              |   918K|    15M|  3833   (1)| 00:00:01 |       |       |\n|   6 |      INDEX FULL SCAN    | PROMO_PK     |   503 |  2012 |     1   (0)| 00:00:01 |       |       |\n|   7 |      PARTITION RANGE ALL|              |   918K|    12M|  3830   (1)| 00:00:01 |     1 |    15 |\n|   8 |       TABLE ACCESS FULL | SALES        |   918K|    12M|  3830   (1)| 00:00:01 |     1 |    15 |\n|*  9 |    INDEX UNIQUE SCAN    | PROMO_PK     |     1 |     4 |     0   (0)| 00:00:01 |       |       |\n|* 10 |    INDEX UNIQUE SCAN    | CUSTOMERS_PK |     1 |     5 |     1   (0)| 00:00:01 |       |       |\n--------------------------------------------------------------------------------------------------------\n\nSH&gt;select sum(s.amount_sold)\n  2  from   sales s\n  3  join to one (customers c  on c.cust_id = s.cust_id, promotions p  on p.promo_id = s.promo_id );\n\nSUM(S.AMOUNT_SOLD)\n------------------\n        98205831.2\n\nElapsed: 00:00:01.77\n\nExecution Plan\n----------------------------------------------------------\nPlan hash value: 4010859135\n\n-----------------------------------------------------------------------------------------------------------\n| Id  | Operation               | Name            | Rows  | Bytes | Cost (%CPU)| Time     | Pstart| Pstop |\n-----------------------------------------------------------------------------------------------------------\n|   0 | SELECT STATEMENT        |                 |     1 |    14 |  1841K  (1)| 00:01:12 |       |       |\n|   1 |  SORT AGGREGATE         |                 |     1 |    14 |            |          |       |       |\n|   2 |   MERGE JOIN OUTER      |                 |   918K|    12M|  1841K  (1)| 00:01:12 |       |       |\n|   3 |    MERGE JOIN OUTER     |                 |   918K|    12M|  1841K  (1)| 00:01:12 |       |       |\n|   4 |     PARTITION RANGE ALL |                 |   918K|    12M|  3824   (1)| 00:00:01 |     1 |    15 |\n|   5 |      TABLE ACCESS FULL  | SALES           |   918K|    12M|  3824   (1)| 00:00:01 |     1 |    15 |\n|   6 |     BUFFER SORT         |                 |     1 |       |  1838K  (1)| 00:01:12 |       |       |\n|   7 |      VIEW               | VW_LAT_A22CA6F4 |     1 |       |     2   (0)| 00:00:01 |       |       |\n|*  8 |       FILTER            |                 |       |       |            |          |       |       |\n|*  9 |        INDEX UNIQUE SCAN| CUSTOMERS_PK    |     1 |     5 |     1   (0)| 00:00:01 |       |       |\n|* 10 |        INDEX UNIQUE SCAN| CUSTOMERS_PK    |     1 |     5 |     1   (0)| 00:00:01 |       |       |\n|  11 |    BUFFER SORT          |                 |     1 |       |  1841K  (1)| 00:01:12 |       |       |\n|  12 |     VIEW                | VW_LAT_A22CA6F4 |     1 |       |     0   (0)| 00:00:01 |       |       |\n|* 13 |      FILTER             |                 |       |       |            |          |       |       |\n|* 14 |       INDEX UNIQUE SCAN | PROMO_PK        |     1 |     4 |     0   (0)| 00:00:01 |       |       |\n|* 15 |       INDEX UNIQUE SCAN | PROMO_PK        |     1 |     4 |     0   (0)| 00:00:01 |       |       |\n-----------------------------------------------------------------------------------------------------------\n\n\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 7: Comparing regular JOIN and <code>JOIN TO ONE<\/code> with supporting indexes<\/em><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The regular <code>INNER JOIN<\/code> query finishes in 0.25 seconds. Adding <code>JOIN TO ONE<\/code> with explicit <code>INNER JOIN<\/code>s increases the runtime to 0.42 seconds. The basic join strategy remains the same, but the <code>FILTER<\/code> operation performs an additional <code>INDEX UNIQUE SCAN<\/code> for each of the two dimension tables to verify the <code>TO ONE<\/code> condition.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With the implicit <code>LEFT OUTER JOIN<\/code>, the difference is more pronounced. Oracle uses the lateral views we have just seen in the CBO trace, together with <code>MERGE JOIN OUTER<\/code> operations. The query takes 1.77 seconds \u2014 still perfectly manageable, but about seven times longer than the regular join.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So, with efficient index lookups available, the additional runtime checks have a measurable but still manageable cost. But indexes and constraints are not always an option.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">More complex join conditions, temporal validity, joins to views, or intermediate processing structures may not allow the <code>TO ONE<\/code> guarantee to be expressed through a primary or unique key constraint at all. And whenever larger amounts of data are processed \u2014 in ETL transformations, intermediate processing steps, or large batch jobs \u2014 full table scans and hash joins without any supporting indexes can be exactly what we want.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What If There Are No Indexes?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This brings us to the other part of the experiment: what happens to the runtime checks when there are no supporting indexes?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rather than constructing yet another test case, let&#8217;s simply use the same SH queries again, this time without indexes on <code>CUSTOMERS<\/code> and <code>PROMOTIONS<\/code> (Listing 8).<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: sql; highlight: [12,39,69]; title: ; notranslate\" title=\"\">\nONF&gt;select sum(s.amount_sold)\n  2  from   sales s\n  3  join   customers c\n  4    on   c.cust_id = s.cust_id\n  5  join   promotions p\n  6    on   p.promo_id = s.promo_id;\n\nSUM(S.AMOUNT_SOLD)\n------------------\n        98205831.2\n\nElapsed: 00:00:00.10\n\nExecution Plan\n----------------------------------------------------------\nPlan hash value: 2669048432\n\n-----------------------------------------------------------------------------------\n| Id  | Operation            | Name       | Rows  | Bytes | Cost (%CPU)| Time     |\n-----------------------------------------------------------------------------------\n|   0 | SELECT STATEMENT     |            |     1 |    23 |  1663   (1)| 00:00:01 |\n|   1 |  SORT AGGREGATE      |            |     1 |    23 |            |          |\n|*  2 |   HASH JOIN          |            |   918K|    20M|  1663   (1)| 00:00:01 |\n|   3 |    TABLE ACCESS FULL | CUSTOMERS  | 55500 |   270K|   422   (0)| 00:00:01 |\n|*  4 |    HASH JOIN         |            |   918K|    15M|  1238   (1)| 00:00:01 |\n|   5 |     TABLE ACCESS FULL| PROMOTIONS |   503 |  2012 |     6   (0)| 00:00:01 |\n|   6 |     TABLE ACCESS FULL| SALES      |   918K|    12M|  1230   (1)| 00:00:01 |\n-----------------------------------------------------------------------------------\n\nONF&gt;select sum(s.amount_sold)\n  2  from   sales s\n  3  join to one (INNER JOIN customers c  on c.cust_id = s.cust_id \n  4               INNER JOIN promotions p  on p.promo_id = s.promo_id );\n\nSUM(S.AMOUNT_SOLD)\n------------------\n        98205831.2\n\nElapsed: 00:29:08.80\n\nExecution Plan\n----------------------------------------------------------\nPlan hash value: 298750268\n\n------------------------------------------------------------------------------------\n| Id  | Operation             | Name       | Rows  | Bytes | Cost (%CPU)| Time     |\n------------------------------------------------------------------------------------\n|   0 | SELECT STATEMENT      |            |     1 |    23 |  2060K  (1)| 00:01:21 |\n|   1 |  SORT AGGREGATE       |            |     1 |    23 |            |          |\n|*  2 |   FILTER              |            |       |       |            |          |\n|*  3 |    HASH JOIN          |            |   918K|    20M|  1668   (2)| 00:00:01 |\n|   4 |     TABLE ACCESS FULL | CUSTOMERS  | 55500 |   270K|   422   (0)| 00:00:01 |\n|*  5 |     HASH JOIN         |            |   918K|    15M|  1243   (2)| 00:00:01 |\n|   6 |      TABLE ACCESS FULL| PROMOTIONS |   503 |  2012 |     6   (0)| 00:00:01 |\n|   7 |      TABLE ACCESS FULL| SALES      |   918K|    12M|  1235   (1)| 00:00:01 |\n|*  8 |    TABLE ACCESS FULL  | PROMOTIONS |     1 |     4 |     6   (0)| 00:00:01 |\n|*  9 |    TABLE ACCESS FULL  | CUSTOMERS  |     1 |     5 |   423   (1)| 00:00:01 |\n------------------------------------------------------------------------------------\n\nONF&gt;select sum(s.amount_sold)\n  2  from   sales s\n  3  join to one (customers c  on c.cust_id = s.cust_id\n  4             , promotions p  on p.promo_id = s.promo_id );\n\nSUM(S.AMOUNT_SOLD)\n------------------\n        98205831.2\n\nElapsed: 01:02:26.11\n\nExecution Plan\n----------------------------------------------------------\nPlan hash value: 3695551726\n\n-------------------------------------------------------------------------------------------\n| Id  | Operation               | Name            | Rows  | Bytes | Cost (%CPU)| Time     |\n-------------------------------------------------------------------------------------------\n|   0 | SELECT STATEMENT        |                 |     1 |    14 |   787M  (1)| 08:32:42 |\n|   1 |  SORT AGGREGATE         |                 |     1 |    14 |            |          |\n|   2 |   MERGE JOIN OUTER      |                 |   918K|    12M|   787M  (1)| 08:32:42 |\n|   3 |    MERGE JOIN OUTER     |                 |   918K|    12M|   776M  (1)| 08:25:31 |\n|   4 |     TABLE ACCESS FULL   | SALES           |   918K|    12M|  1230   (1)| 00:00:01 |\n|   5 |     BUFFER SORT         |                 |     1 |       |   776M  (1)| 08:25:31 |\n|   6 |      VIEW               | VW_LAT_A22CA6F4 |     1 |       |   845   (1)| 00:00:01 |\n|*  7 |       FILTER            |                 |       |       |            |          |\n|*  8 |        TABLE ACCESS FULL| CUSTOMERS       |     1 |     5 |   423   (1)| 00:00:01 |\n|*  9 |        TABLE ACCESS FULL| CUSTOMERS       |     1 |     5 |   423   (1)| 00:00:01 |\n|  10 |    BUFFER SORT          |                 |     1 |       |   787M  (1)| 08:32:42 |\n|  11 |     VIEW                | VW_LAT_A22CA6F4 |     1 |       |    12   (0)| 00:00:01 |\n|* 12 |      FILTER             |                 |       |       |            |          |\n|* 13 |       TABLE ACCESS FULL | PROMOTIONS      |     1 |     4 |     6   (0)| 00:00:01 |\n|* 14 |       TABLE ACCESS FULL | PROMOTIONS      |     1 |     4 |     6   (0)| 00:00:01 |\n-------------------------------------------------------------------------------------------\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong><em>Listing 8: Comparing regular JOIN and <code>JOIN TO ONE<\/code> without supporting indexes<\/em><\/strong><\/p>\n\n\n\n<table id=\"tablepress-4\" class=\"tablepress tablepress-id-4\">\n<thead>\n<tr class=\"row-1\">\n\t<th class=\"column-1\"><strong>Query<\/strong><\/th><th class=\"column-2\"><strong>With indexes<\/strong><\/th><th class=\"column-3\"><strong>Without indexes<\/strong><\/th>\n<\/tr>\n<\/thead>\n<tbody class=\"row-striping row-hover\">\n<tr class=\"row-2\">\n\t<td class=\"column-1\">Regular INNER JOIN<\/td><td class=\"column-2\">0.25 s<\/td><td class=\"column-3\">0.10 s<\/td>\n<\/tr>\n<tr class=\"row-3\">\n\t<td class=\"column-1\">INNER JOIN TO ONE<\/td><td class=\"column-2\">0.42 s<\/td><td class=\"column-3\">29 min 09 s<\/td>\n<\/tr>\n<tr class=\"row-4\">\n\t<td class=\"column-1\">LEFT OUTER JOIN TO ONE<\/td><td class=\"column-2\">1.77 s<\/td><td class=\"column-3\">1 h 02 min<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<!-- #tablepress-4 from cache -->\n\n\n<p class=\"wp-block-paragraph\">The regular join is actually faster in this setup and finishes in just 0.10 seconds. Oracle full-scans the three tables and combines them using two hash joins.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With explicit <code>INNER JOIN TO ONE<\/code>, however, the runtime jumps to <strong>29 minutes<\/strong>. The two hash joins are still there, but so are the correlated runtime checks. Without indexes, the correlated checks repeatedly access the corresponding dimension tables using full table scans.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And with the implicit <code>LEFT OUTER JOIN<\/code>, things get even worse: the query takes <strong>more than an hour<\/strong>. The lateral-view transformation now combines the outer joins with repeated full scans of the dimension tables.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So this is where I would be particularly careful with <code>JOIN TO ONE<\/code>: large-volume processing on tables that deliberately have no supporting indexes or validated uniqueness constraints. A regular full-scan\/hash-join plan can be extremely efficient in such a scenario, while the current implementation of the <code>TO ONE<\/code> runtime checks can completely change the performance characteristics of the query.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Of course, missing or disabled constraints are not the only reason why Oracle may have to perform the runtime check. There are also joins for which uniqueness simply cannot be expressed by a primary or unique key constraint \u2014 for example, more complex join conditions, joins involving temporal validity, or joins to views. And there are valid processing scenarios where supporting indexes are deliberately avoided and uniqueness is not enforced through constraints.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In all these cases, the runtime enforcement of <code>TO ONE<\/code> is actually one of the attractive aspects of the feature. Unfortunately, with the current implementation, it is exactly these cases where the additional check can become expensive.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Final thoughts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These tests add an important performance perspective to the conclusions from my previous post, particularly for data warehouse workloads. With its current implementation, for queries processing larger amounts of data, I would use it only where the <code>TO ONE<\/code> semantics are already guaranteed by an <code>ENABLE VALIDATE<\/code> primary or unique key constraint and no additional runtime check is required.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This may look quite different in OLTP or application development. There, joins often involve only a small number of rows, and properly defined and enabled constraints are often part of the application data model anyway. Even where a runtime check is required, its cost may be negligible \u2014 and if it isn&#8217;t, that might actually be a good reason to revisit the data model.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I still very much like the concept behind <code>JOIN TO ONE<\/code>, particularly the ability to detect accidental row multiplication where constraints cannot provide that guarantee. Unfortunately, these are precisely the cases where the current implementation makes me reluctant to use it for large-volume processing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But this is still a very young feature. I hope Oracle is already working on making the runtime enforcement more efficient. If that part can be improved, my conclusion could look quite different.<\/p>\n\n\n<div id=\"sqlor-2371350028\"><script async src=\"\/\/pagead2.googlesyndication.com\/pagead\/js\/adsbygoogle.js?client=ca-pub-2925154690547867\" crossorigin=\"anonymous\"><\/script><ins class=\"adsbygoogle\" style=\"display:block; text-align:center;\" data-ad-client=\"ca-pub-2925154690547867\" \ndata-ad-slot=\"2727403138\" \ndata-ad-layout=\"in-article\"\ndata-ad-format=\"fluid\"><\/ins>\n<script> \n(adsbygoogle = window.adsbygoogle || []).push({}); \n<\/script>\n<\/div>\n\n<div class=\"crp-list-container\"><h3 class=\"crp-list-title\">Related Posts<\/h3><ul class=\"crp-list\"><li class=\"crp-list-item crp-list-item-image-none\"><div class=\"crp-list-item-title\"><a href=\"https:\/\/blog.sqlora.com\/en\/oracle-26ai-a-closer-look-at-join-to-one\/\" target=\"_blank\">Oracle 26ai: A Closer Look at JOIN TO ONE<\/a><\/div><\/li><\/ul><\/div>","protected":false},"excerpt":{"rendered":"<p>Sometimes the most interesting findings start with a simple question. After publishing my recent post on Oracle 26ai JOIN TO ONE, Iudith Mentzel pointed out something in one of the query transformations that looked perfectly logical &#8211; but turned out not to describe what Oracle actually does. A small test led me one level deeper, [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2816,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[162,50,2],"tags":[145,166,164,168,51,169],"class_list":["post-2778","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-26ai","category-data-warehouse","category-oracle","tag-cbo","tag-join-to-one","tag-oracle-26ai","tag-query-transformation","tag-sql","tag-sql-performance"],"_links":{"self":[{"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/posts\/2778","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/comments?post=2778"}],"version-history":[{"count":54,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/posts\/2778\/revisions"}],"predecessor-version":[{"id":2842,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/posts\/2778\/revisions\/2842"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/media\/2816"}],"wp:attachment":[{"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/media?parent=2778"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/categories?post=2778"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.sqlora.com\/en\/wp-json\/wp\/v2\/tags?post=2778"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}