Chapter 05 · Core Querying: Joins, Subqueries, APPLY, CTEs, and Set Operations

INNER/OUTER/CROSS Joins, Cardinality, Join Predicates, and Duplicate Multiplication

Reason from relationship cardinality and output grain through INNER/OUTER/CROSS joins, fan-out, null-extension, and predicate placement before tuning join plans.

Intermediate105–130 minutesJoin cardinality + fan-out labSQL Server 2025 · compatibility 170Developer/Express · disposable lab05 tablesLast reviewed: August 2026

Learning outcomes

A ServiceHub dashboard joins work orders to technicians, visits, and tags. The developer expects four work orders but sees twelve rows and concludes that SQL Server “duplicated” data. Nothing was duplicated by the engine: the join predicate correctly formed combinations implied by one-to-many relationships. This lesson builds a row-count mental model before discussing join algorithms or indexes.

01

Predict INNER, LEFT OUTER, and CROSS JOIN cardinality from relationship multiplicity and predicates.

02

Explain fan-out/duplicate multiplication without using DISTINCT as a reflexive repair.

03

Place outer-join predicates deliberately in ON versus WHERE and predict null-extension.

04

Distinguish logical join semantics from physical Nested Loops, Hash Match, or Merge Join choices.

05

Use counts, keys, constraints, and execution-plan estimates to diagnose row-shape problems before tuning.

Lab boundary

This lesson creates only disposable lab05 tables. It references the stable ops.WorkOrder identifiers 1001–1004 from the Chapter 01 ServiceHub seed. If those rows are absent, rerun the Chapter 01 lab before continuing.

1. Cardinality begins with the relationship, not JOIN syntax

An INNER JOIN returns combinations for which its ON predicate is TRUE. If one work order has two visits, joining WorkOrder to Visit can legitimately return two rows for that work order. Add three tags and a naive join across both one-to-many relationships can yield six combinations for that work order. The repeated work-order columns are not evidence of corruption; they reflect the grain of the joined result.

sql · create deterministic one-to-many data
USE ServiceHubLab;GOIF SCHEMA_ID(N'lab05') IS NULL EXEC(N'CREATE SCHEMA lab05 AUTHORIZATION dbo;');GODROP TABLE IF EXISTS lab05.WorkOrderTag;DROP TABLE IF EXISTS lab05.WorkOrderVisit;GOCREATE TABLE lab05.WorkOrderVisit(    visit_id int IDENTITY(1,1) NOT NULL CONSTRAINT PK_lab05_Visit PRIMARY KEY,    work_order_id bigint NOT NULL,    visit_started_at datetime2(0) NOT NULL,    outcome varchar(20) NOT NULL);CREATE TABLE lab05.WorkOrderTag(    work_order_id bigint NOT NULL,    tag varchar(20) NOT NULL,    CONSTRAINT PK_lab05_Tag PRIMARY KEY(work_order_id, tag));GOINSERT lab05.WorkOrderVisit(work_order_id,visit_started_at,outcome) VALUES(1001,'2026-08-10T10:00:00','inspection'),(1001,'2026-08-10T11:30:00','follow-up'),(1002,'2026-08-10T11:00:00','replacement'),(1004,'2026-08-11T09:30:00','calibration');INSERT lab05.WorkOrderTag(work_order_id,tag) VALUES(1001,'pump'),(1001,'urgent'),(1001,'north'),(1002,'sensor'),(1004,'meter');GO

The tables deliberately omit foreign keys so the lesson can focus on query grain; Chapter 04 already taught integrity design. In production, model the relationship explicitly and index foreign-key/access columns when workload evidence supports it.

2. Predict fan-out before running the join

For work order 1001, Visit has 2 rows and Tag has 3 rows. Joining both independently by work_order_id produces 2 × 3 = 6 combinations for that order. If the report grain is “one row per work order,” the query is wrong even if every JOIN is syntactically valid.

sql · observe multiplication
USE ServiceHubLab;GOSELECT w.work_order_id,       v.visit_id,       t.tagFROM ops.WorkOrder AS wJOIN lab05.WorkOrderVisit AS v  ON v.work_order_id = w.work_order_idJOIN lab05.WorkOrderTag AS t  ON t.work_order_id = w.work_order_idWHERE w.work_order_id = 1001ORDER BY v.visit_id, t.tag;GOSELECT COUNT(*) AS joined_rowsFROM lab05.WorkOrderVisit AS vJOIN lab05.WorkOrderTag AS t  ON t.work_order_id = v.work_order_idWHERE v.work_order_id = 1001;GO

Expected count: 6. Adding DISTINCT w.work_order_id could hide the multiplication in the final projection, but it does not repair the logical mistake if you also need visit or tag facts. First choose the target grain: aggregate tags separately, choose one visit, or return a detail result whose multiplicity is intentional.

3. LEFT JOIN preserves the left side through null-extension

A LEFT OUTER JOIN keeps each left row even if there is no matching right row. For an unmatched row, columns from the right input are null-extended. A predicate in the ON clause participates in deciding which right rows match. A predicate on right-side columns in the WHERE clause runs after the join and can reject those NULL-extended rows, effectively turning the query into inner-like behavior for that condition.

sql · ON versus WHERE in an outer join
USE ServiceHubLab;GO-- Keep every work order; match only follow-up visits.SELECT w.work_order_id, v.visit_id, v.outcomeFROM ops.WorkOrder AS wLEFT JOIN lab05.WorkOrderVisit AS v  ON v.work_order_id = w.work_order_id AND v.outcome = 'follow-up'ORDER BY w.work_order_id, v.visit_id;GO-- Different meaning: NULL-extended rows fail this WHERE predicate.SELECT w.work_order_id, v.visit_id, v.outcomeFROM ops.WorkOrder AS wLEFT JOIN lab05.WorkOrderVisit AS v  ON v.work_order_id = w.work_order_idWHERE v.outcome = 'follow-up'ORDER BY w.work_order_id, v.visit_id;GO

The first query answers “all work orders, plus a follow-up visit when one exists.” The second answers “rows whose joined visit is a follow-up,” excluding work orders with no such match. Moving predicates between ON and WHERE is therefore a semantic change for outer joins, not merely a formatting preference.

4. CROSS JOIN is explicit Cartesian product

A CROSS JOIN returns every combination of rows from its two inputs. This is useful for generating matrices—such as every technician × every target status—but dangerous when accidental. Old comma-separated FROM syntax can also create a Cartesian product if a predicate is missing; prefer explicit JOIN syntax so code review can see the intended relationship.

sql · a controlled matrix and an accidental explosion
USE ServiceHubLab;GOWITH TargetStatus AS(    SELECT 'new' AS status_name    UNION ALL SELECT 'assigned'    UNION ALL SELECT 'onsite')SELECT t.technician_code, s.status_nameFROM ops.Technician AS tCROSS JOIN TargetStatus AS sWHERE t.is_active = 1ORDER BY t.technician_code, s.status_name;GO-- Do not run this pattern against large tables without understanding the product:SELECT COUNT_BIG(*) AS cartesian_rowsFROM ops.WorkOrder AS wCROSS JOIN lab05.WorkOrderTag AS t;GO

The count is the product of input row counts. A cross product can be legitimate, but the query should make that business intention obvious and bound the inputs before scale makes the result impractical.

5. Estimates can expose a modeling/query problem

The optimizer estimates join cardinality from statistics, constraints, predicates, and its cardinality-estimation model. A poor estimate can be caused by stale/insufficient statistics or correlations, but also by a query whose join predicates do not represent the real relationship. Do not respond to every estimate mismatch with “add an index.” First compare actual row counts to the grain you intended.

sql · measure row shape before tuning
USE ServiceHubLab;GOSET STATISTICS IO ON;SELECT w.work_order_id,       COUNT(DISTINCT v.visit_id) AS visit_count,       COUNT(DISTINCT t.tag) AS tag_count,       COUNT_BIG(*) AS joined_combinationsFROM ops.WorkOrder AS wLEFT JOIN lab05.WorkOrderVisit AS v ON v.work_order_id = w.work_order_idLEFT JOIN lab05.WorkOrderTag AS t ON t.work_order_id = w.work_order_idGROUP BY w.work_order_idORDER BY w.work_order_id;SET STATISTICS IO OFF;GO

Inspect the actual execution plan in your supported tool if available. Compare estimated versus actual rows at joins, but interpret the mismatch alongside keys and relationship cardinality. Chapter 10 will go deeply into cardinality estimation; here the objective is to prove the row semantics first.

6. Failure analysis and production judgment

Wrong approach

“The join returns duplicates, so add DISTINCT.” DISTINCT can mask an incorrect grain and adds its own duplicate-elimination work. It can also silently discard genuinely distinct detail rows after an incomplete projection.

Repair

Write down the intended output grain and the expected multiplicity of every relationship. Join on complete keys, aggregate or select detail intentionally, and use DISTINCT only when set semantics genuinely require duplicate elimination.

Logical join types are edition-neutral and available on free Developer/Express. Physical algorithms are optimizer choices and can vary with data, statistics, memory, indexes, compatibility level, and parallelism. Do not force a join algorithm as a first-line repair for a semantic row-count error. Monitor unexpected row growth, estimates versus actuals, memory grants/spills later in the course, and application assumptions about one-row-per-entity.

Check your understanding

  1. Why can one work order appear six times after joining two visits and three tags?
  2. What is the semantic difference between a right-side filter in LEFT JOIN ON and the same filter in WHERE?
  3. When is CROSS JOIN appropriate?
  4. Why is DISTINCT a risky default response to fan-out?
  5. Does seeing a Hash Match prove the query should have been written differently?
Review the answers

The result contains the Cartesian combinations of matching child rows: 2 × 3.

ON controls which right rows match while preserving the left row; WHERE can reject the resulting NULL-extended row.

When the business requirement is intentionally every combination of bounded inputs, such as a matrix.

It can hide a wrong result grain and add duplicate-elimination work rather than fixing relationship semantics.

No. Hash Match is a physical implementation choice; query correctness comes from logical join semantics and predicates.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.