A correlated subquery reads a value from an outer query and uses that value inside an inner query. The syntax can describe a business rule naturally, but it can also invite repeated work: for many outer rows, Db2 might perform many inner probes, scans, aggregates, or sorts. Correlation is therefore a tuning warning, not an automatic defect. Db2 for z/OS can transform some correlated SQL, and a selective, indexed EXISTS test can be exactly the right solution. This tutorial shows how to recognize the expensive cases, preserve SQL semantics, and verify every rewrite with evidence.
A query contains separate query blocks. An inner block is correlated when it references a column exposed by an outer block. In the following statement, the inner alias I refers to INVOICE, while C belongs to the outer CUSTOMER block. The predicate I.CUSTOMER_ID = C.CUSTOMER_ID connects the two blocks and gives the subquery a different search value for each customer.
1234567SELECT C.CUSTOMER_ID, C.CUSTOMER_NAME FROM CUSTOMER AS C WHERE (SELECT COUNT(*) FROM INVOICE AS I WHERE I.CUSTOMER_ID = C.CUSTOMER_ID AND I.PAYMENT_STATUS = 'UNPAID') > 0;
The logical model says to consider each customer and evaluate the inner condition using that customer's identifier. Do not turn that model into a literal promise that Db2 always executes the written subquery once per row. The optimizer can decorrelate or transform eligible SQL, choose a join method, and reorder work while preserving the result. Conversely, readable SQL can still produce repeated nested-loop probes. The selected access path—not indentation—is the evidence.
The dangerous combination is a large outer result and expensive inner work. If 500,000 customers reach the correlated block and each probe scans many invoice pages, total work can grow dramatically. Even an individually quick probe matters when multiplied by a high execution count. A correlated aggregate can be worse because Db2 may need to find and process every matching inner row before producing COUNT, SUM, AVG, MIN, or MAX.
Cost is not determined by table size alone. A very large INVOICE table can support fast probes when CUSTOMER_ID and the relevant filter columns form selective index access. A small inner table might be cheaper to scan once and join. Outer filtering also matters: ten key-selected customers create a different problem from an unfiltered customer scan.
COUNT answers how many rows qualify. If the application needs only yes or no, EXISTS is the direct operator. The select list inside EXISTS does not define output data; the truth value depends only on whether a qualifying row exists. Duplicate inner matches do not change the answer. This gives Db2 a clear semijoin-style requirement and avoids asking SQL to compute a complete count solely to compare it with zero.
123456789101112131415161718-- Avoid counting every matching invoice for a yes/no question SELECT C.CUSTOMER_ID, C.CUSTOMER_NAME FROM CUSTOMER AS C WHERE (SELECT COUNT(*) FROM INVOICE AS I WHERE I.CUSTOMER_ID = C.CUSTOMER_ID AND I.PAYMENT_STATUS = 'UNPAID') > 0; -- Express the requirement directly SELECT C.CUSTOMER_ID, C.CUSTOMER_NAME FROM CUSTOMER AS C WHERE EXISTS (SELECT 1 FROM INVOICE AS I WHERE I.CUSTOMER_ID = C.CUSTOMER_ID AND I.PAYMENT_STATUS = 'UNPAID');
EXISTS does not force one particular access path, and writing SELECT 1 is a convention, not a special optimization command. Db2 can stop proving existence after a match because later matches cannot change true to something “more true.” The optimizer might also transform the statement. Use NOT EXISTS for “no qualifying related row.” Unlike NOT IN, NOT EXISTS can express an anti-match without the surprising UNKNOWN result caused by a NULL in the NOT IN subquery result, provided the correlation predicates themselves express the intended NULL rules.
A scalar correlated aggregate is common in reports: for every customer, calculate total unpaid balance. A set-based alternative groups INVOICE once by CUSTOMER_ID and joins the grouped result to CUSTOMER. This can expose a broader range of join strategies and avoid logically repeating the aggregate. A CTE improves structure here, but it is not a command to materialize results; Db2 is free to merge, transform, or otherwise optimize it.
123456789101112131415161718192021222324-- Correlated scalar aggregate SELECT C.CUSTOMER_ID, C.CUSTOMER_NAME, (SELECT SUM(I.AMOUNT_DUE) FROM INVOICE AS I WHERE I.CUSTOMER_ID = C.CUSTOMER_ID AND I.PAYMENT_STATUS = 'UNPAID') AS UNPAID_TOTAL FROM CUSTOMER AS C; -- Set-based rewrite WITH UNPAID_BY_CUSTOMER AS ( SELECT I.CUSTOMER_ID, SUM(I.AMOUNT_DUE) AS UNPAID_TOTAL FROM INVOICE AS I WHERE I.PAYMENT_STATUS = 'UNPAID' GROUP BY I.CUSTOMER_ID ) SELECT C.CUSTOMER_ID, C.CUSTOMER_NAME, U.UNPAID_TOTAL FROM CUSTOMER AS C LEFT JOIN UNPAID_BY_CUSTOMER AS U ON U.CUSTOMER_ID = C.CUSTOMER_ID;
The LEFT JOIN is important. For a customer with no unpaid invoice, the scalar SUM returns NULL and the outer customer row remains. An INNER JOIN would remove that customer and change the answer. Adding COALESCE(U.UNPAID_TOTAL, 0) is valid only if the business rule says “no invoices means zero”; it changes NULL to zero and should be intentional. Aggregating before joining also guarantees one grouped row per customer key, preventing invoice duplicates from multiplying outer rows.
Developers often correlate MAX to find the latest event for each order. The pattern can return more than one row when multiple events share the same timestamp. A window function can rank each group once and apply an explicit tie-breaker. ROW_NUMBER chooses one row; RANK or DENSE_RANK intentionally keeps ties. Those are different requirements, so choose before tuning.
123456789101112131415161718192021222324252627-- Correlated latest timestamp; ties can return multiple rows SELECT E.ORDER_ID, E.EVENT_TYPE, E.EVENT_TS FROM ORDER_EVENT AS E WHERE E.EVENT_TS = (SELECT MAX(E2.EVENT_TS) FROM ORDER_EVENT AS E2 WHERE E2.ORDER_ID = E.ORDER_ID); -- Deterministically choose one latest row per order WITH RANKED_EVENT AS ( SELECT E.ORDER_ID, E.EVENT_TYPE, E.EVENT_TS, E.EVENT_ID, ROW_NUMBER() OVER (PARTITION BY E.ORDER_ID ORDER BY E.EVENT_TS DESC, E.EVENT_ID DESC) AS RN FROM ORDER_EVENT AS E ) SELECT ORDER_ID, EVENT_TYPE, EVENT_TS FROM RANKED_EVENT WHERE RN = 1;
EVENT_ID makes the ordering deterministic when timestamps tie. If the requirement is to return every event at the maximum timestamp, the original semantics or a DENSE_RANK rewrite may be appropriate instead. Window processing can require sorting unless an access path supplies useful order, so it is not automatically cheaper. It often becomes attractive when the original query evaluates a group aggregate for many rows.
Window functions can also replace repeated group comparisons. For example, an employee compared with the average salary of that employee's department can use AVG(SALARY) OVER (PARTITION BY DEPARTMENT_ID). The window result remains attached to each employee row, avoiding a separate aggregate join. Put the window expression in a nested table expression or CTE, then filter the derived average in the outer query.
| Requirement | Useful pattern | Semantic or tuning caution |
|---|---|---|
| Test whether at least one related row exists | EXISTS or NOT EXISTS | Do not count every match merely to answer yes or no |
| Compare with one aggregate per business key | Pre-aggregate in a CTE, then join | Preserve outer rows and NULL behavior during the rewrite |
| Return one latest row per group | ROW_NUMBER with a deterministic tie-breaker | MAX alone can return multiple tied rows |
| Probe a few outer rows through a selective key | Correlated SQL can be appropriate | Verify index access and actual outer cardinality |
Correlated syntax is not inherently wrong. EXISTS and NOT EXISTS often provide the clearest statement of a semijoin or antijoin rule. A transaction that retrieves one account and checks for one matching exception may perform only a few selective probes. Rewriting that statement into a large pre-aggregation could process far more data. Correlation can also be the cleanest way to express conditions whose meaning genuinely depends on each outer value.
Prefer clarity first, then verify. A longer join is not automatically faster, and a CTE is not automatically evaluated once. Db2's cost-based optimizer considers available indexes, statistics, predicates, join methods, sort requirements, parallelism, and estimated cardinalities. Tune the chosen plan and workload, not a visual SQL style.
For I.CUSTOMER_ID = C.CUSTOMER_ID AND I.PAYMENT_STATUS = 'UNPAID', an index beginning with CUSTOMER_ID can let Db2 probe one customer's keys. An index on (CUSTOMER_ID, PAYMENT_STATUS) can narrow the matching range further. Reversing the keys to (PAYMENT_STATUS, CUSTOMER_ID) might support other workloads and can still match both equalities, but key order affects clustering, filtering patterns, skip behavior, and queries that provide only one predicate. There is no universal order detached from the workload.
1234567-- Illustrative index for the EXISTS example CREATE INDEX APP.IX_INVOICE_CUSTOMER_STATUS ON APP.INVOICE (CUSTOMER_ID, PAYMENT_STATUS); -- Illustrative index for latest-event probes or ordering CREATE INDEX APP.IX_EVENT_ORDER_TIME ON APP.ORDER_EVENT (ORDER_ID, EVENT_TS DESC, EVENT_ID DESC);
Extra indexes consume disk and buffer-pool space and add work to INSERT, DELETE, UPDATE, logging, COPY, RECOVER, REORG, and RUNSTATS. Include columns needed only for index-only access when measured savings justify that operational cost. Keep catalog statistics current, including suitable column-group statistics when correlated predicates have related distributions. An index definition does not guarantee Db2 will choose it.
A scalar fullselect can return one value, or no row represented as NULL in its scalar context. If it returns multiple rows, Db2 can issue SQLCODE -811. Adding FETCH FIRST 1 ROW ONLY merely to suppress the error hides ambiguous data unless an ORDER BY defines which row is correct. Enforce a unique business key or aggregate deliberately.
EXISTS returns each qualifying outer row once regardless of how many inner rows match. Replacing it with an ordinary inner join can repeat the outer row for every match. SELECT DISTINCT may hide the multiplication but adds work and can conceal a faulty join. Use a semijoin-style EXISTS, join to a pre-deduplicated key set, or group at the correct business grain.
Equality does not match NULL to NULL because NULL = NULL is UNKNOWN. A correlated predicate on nullable keys therefore needs a defined business rule. NOT IN is especially risky: one NULL returned by its subquery can make every comparison UNKNOWN. NOT EXISTS avoids that particular trap, but nullable correlation values and explicit IS NULL logic still require review. Never assume COALESCE is harmless; replacing NULL with a sentinel can collide with real data and affect indexability.
Run EXPLAIN for the original and rewritten statements under comparable conditions: current statistics, the same Db2 function level, bind options, special registers, and representative parameter assumptions. Query text alone cannot prove repeated execution or improvement. Visual EXPLAIN tools and Db2 explain tables can show whether Db2 transformed a subquery, selected an index, estimated many probes, introduced a sort, or chose a different join sequence.
123456789101112131415161718192021EXPLAIN PLAN SET QUERYNO = 9410 FOR SELECT C.CUSTOMER_ID, C.CUSTOMER_NAME FROM CUSTOMER AS C WHERE EXISTS (SELECT 1 FROM INVOICE AS I WHERE I.CUSTOMER_ID = C.CUSTOMER_ID AND I.PAYMENT_STATUS = 'UNPAID'); SELECT QUERYNO, PLANNO, METHOD, ACCESSTYPE, MATCHCOLS, ACCESSCREATOR, ACCESSNAME, INDEXONLY FROM PLAN_TABLE WHERE QUERYNO = 9410 ORDER BY PLANNO;
| Evidence | What it helps answer |
|---|---|
| ACCESSTYPE and ACCESSNAME | Show the chosen table or index access and the selected index name |
| MATCHCOLS | Shows how many leading index key columns are used for matching |
| PLANNO, METHOD, and join sequence | Help reveal the order and method used to combine query blocks and tables |
| Predicate-table details | Show predicate type, stage, selectivity, and when predicates are applied |
| Accounting and monitoring data | Confirm CPU, elapsed time, getpages, reads, executions, and rows in practice |
Do not optimize for one PLAN_TABLE field. More MATCHCOLS can be useful, but total cost, cardinality, join order, index-only access, sorts, prefetch, and data-page access also matter. After EXPLAIN, measure the statement in a representative environment. Compare returned rows first, then CPU, elapsed time, getpages, synchronous reads, sort activity, outer rows, inner probe frequency, and executions per business transaction.
Imagine a teacher has 1,000 student cards and asks, for each card, “Does this student have an overdue book?” Walking to the library and searching every shelf 1,000 times is the expensive correlated pattern. A good index is like a shelf list arranged by student, so each check is quick. EXISTS means the teacher stops as soon as one overdue book is found. A CTE and join are like asking the librarian to make one overdue-student list, then matching all cards against it. Neither method always wins: for two students, quick lookups may be best; for every student, one prepared list may be better. EXPLAIN shows which method Db2 plans to use.
1. What makes a subquery correlated?
2. Which form best answers whether any unpaid invoice exists?
3. Why is a scalar correlated subquery risky when its key is not unique?
4. Which index often supports an EXISTS probe by customer and status?
5. What is a major semantic risk when rewriting a scalar aggregate as a join?
6. What should be compared after rewriting correlated SQL?
Learn scalar, row, table, correlated, and uncorrelated query structures
Express semijoin and antijoin requirements while preserving duplicate semantics
Structure reusable query blocks and set-based aggregate rewrites
Rank rows, calculate group values, and solve latest-row requirements