Chapter 04 · Core SQL Querying: Filtering, Joins, Subqueries, CTEs, and Set Operations

INNER, OUTER, CROSS, and Self Joins with Cardinality Reasoning

Reason about MySQL join cardinality before execution, preserve outer-join semantics, diagnose Cartesian and one-to-many multiplication, and use self joins safely.

Beginner75–95 minJoin cardinality labMySQL 8.4 LTS · current downloadable baseline 8.4.10JOIN + cardinalityLast reviewed: August 2026

Learning outcomes

A join is not merely syntax for “put two tables together.” It defines which row combinations are eligible to exist in the result. If you cannot predict the cardinality before running a small join, a plausible-looking output can hide missing rows, duplicate multiplication, or a silently converted outer join. This lesson treats row-count reasoning as a correctness tool.

01

Predict join cardinality from keys, optional relationships, and predicate selectivity before executing a query.

02

Distinguish INNER JOIN, LEFT/RIGHT OUTER JOIN, CROSS JOIN, and self joins in MySQL.

03

Explain why ON predicates and WHERE filters are not interchangeable for outer joins.

04

Diagnose accidental Cartesian products and duplicate multiplication from one-to-many relationships.

05

Use EXPLAIN and exact row counts as evidence while preserving the logical meaning of the join.

Reuse the seed

Rebuild servicehub_query_lab from Lesson 1 if necessary. The expected base counts are 4 customers, 5 technicians, 7 work orders, and 9 work_order_tags rows.

Start with relationships and expected multiplicity

Each work order references exactly one customer, so joining all seven work orders to customers should still produce seven rows. A work order may have no technician, so an inner join to technicians should drop the unassigned row 1003, while a left join should preserve it with NULL-extended technician columns. The tag table is one-to-many: work order 1001 has two tags and work order 1004 has two tags, so joining work orders to tags can legitimately multiply rows.

RelationshipSeed expectationReason
work_orders → customers7 joined rowsEvery work order has one valid customer_id.
work_orders → technicians with INNER JOIN6 joined rows1003 has technician_id NULL and therefore no match.
work_orders → technicians with LEFT JOIN7 joined rowsAll work orders remain; 1003 receives NULL technician columns.
work_orders → tags with INNER JOIN9 joined rowsA work order can have multiple tags, so row multiplication is expected.
sql · verify base relationship counts
USE servicehub_query_lab;SELECT COUNT(*) AS wo_customer_rowsFROM work_orders AS wJOIN customers AS c ON c.customer_id = w.customer_id;SELECT COUNT(*) AS inner_tech_rowsFROM work_orders AS wJOIN technicians AS t ON t.technician_id = w.technician_id;SELECT COUNT(*) AS left_tech_rowsFROM work_orders AS wLEFT JOIN technicians AS t ON t.technician_id = w.technician_id;SELECT COUNT(*) AS wo_tag_rowsFROM work_orders AS wJOIN work_order_tags AS wt ON wt.work_order_id = w.work_order_id;

INNER JOIN: only matched combinations survive

An inner join returns row combinations that satisfy its join condition. MySQL may choose a nested-loop, hash join, or another optimizer strategy depending on indexes and estimates, but those physical choices must preserve the same inner-join result.

sql · join open work orders to customer and technician
SELECT w.work_order_id,       c.customer_name,       t.technician_name,       w.statusFROM work_orders AS wJOIN customers AS c  ON c.customer_id = w.customer_idJOIN technicians AS t  ON t.technician_id = w.technician_idWHERE w.status = 'open'ORDER BY w.work_order_id;

Work order 1003 is absent because its technician_id is NULL and no technician row can satisfy equality with it. If the business question is “show open orders that already have an assigned technician,” this is correct. If the question is “show every open order and display technician information when available,” the join type is wrong.

LEFT JOIN and the ON-versus-WHERE trap

A left outer join preserves every row from its left input. When no right-side row matches, MySQL produces an output row whose right-side columns are NULL. This NULL extension is exactly why a filter on the right table can change the meaning when it is moved from ON to WHERE.

sql · preserve unassigned work orders
SELECT w.work_order_id,       t.technician_nameFROM work_orders AS wLEFT JOIN technicians AS t  ON t.technician_id = w.technician_idWHERE w.status='open'ORDER BY w.work_order_id;
sql · wrong and repaired active-technician conditions
-- Wrong if the requirement is "keep unassigned work orders too".SELECT w.work_order_id, t.technician_nameFROM work_orders AS wLEFT JOIN technicians AS t  ON t.technician_id = w.technician_idWHERE w.status='open'  AND t.active = TRUE;-- The right-side condition participates in matching instead.SELECT w.work_order_id, t.technician_nameFROM work_orders AS wLEFT JOIN technicians AS t  ON t.technician_id = w.technician_id AND t.active = TRUEWHERE w.status='open'ORDER BY w.work_order_id;

In the first query, the NULL-extended unassigned row fails t.active = TRUE in the WHERE clause and disappears. In the second, work orders are still preserved; an inactive or missing technician simply produces NULL right-side columns. Neither query is universally “better.” They answer different questions.

MySQL also supports RIGHT JOIN/RIGHT OUTER JOIN. The optimizer can rewrite right outer joins as left outer joins, and many teams standardize on left joins simply for readability. MySQL 8.4 does not provide a native FULL OUTER JOIN operator; when a genuine full-outer result is required, compose it deliberately from supported operations and test duplicate semantics rather than assuming syntax from another database product.

CROSS JOIN and accidental Cartesian products

A Cartesian product pairs every row from one input with every row from another. It can be intentional—for example, generating every technician/day combination for a planning matrix—but it is often an accident caused by a missing join predicate. With 7 work orders and 5 technicians, the product has 35 rows. At production scale, accidental multiplication can explode memory, temporary-table use, and response size.

sql · observe an intentional small Cartesian product
SELECT COUNT(*) AS product_rowsFROM work_orders AS wCROSS JOIN technicians AS t;-- Expected: 35-- Keep deliberate CROSS JOINs narrow and obvious.SELECT w.work_order_id, t.technician_nameFROM work_orders AS wCROSS JOIN technicians AS tWHERE w.work_order_id IN (1001,1003)  AND t.technician_id IN (11,12)ORDER BY w.work_order_id, t.technician_id;-- Expected: 4 combinations
Failure drill

If a join suddenly returns far more rows than expected, stop before adding DISTINCT as a cosmetic repair. Compare the expected multiplicity with the actual keys and predicates. DISTINCT can hide a cardinality bug while preserving unnecessary work and wrong business semantics.

One-to-many joins multiply rows legitimately

The tag relation demonstrates why “one work order equals one output row” is not always true. Joining a parent to a child table returns one row per matching child unless you aggregate or otherwise reduce the child side.

sql · see duplicate multiplication from tags
SELECT w.work_order_id, w.summary, wt.tagFROM work_orders AS wJOIN work_order_tags AS wt  ON wt.work_order_id = w.work_order_idWHERE w.work_order_id IN (1001,1004)ORDER BY w.work_order_id, wt.tag;SELECT w.work_order_id, COUNT(*) AS tag_countFROM work_orders AS wJOIN work_order_tags AS wt  ON wt.work_order_id = w.work_order_idGROUP BY w.work_order_idORDER BY w.work_order_id;

When a report later joins several independent one-to-many relations, row multiplication can compound. Predict that shape before adding aggregates such as SUM(), otherwise totals can be multiplied too.

Self joins: one table playing two roles

The technicians table contains a supervisor relationship back to itself. A self join uses aliases so the same physical table can represent the employee role and the supervisor role.

sql · technician-to-supervisor self join
SELECT e.technician_id,       e.technician_name AS employee,       s.technician_name AS supervisorFROM technicians AS eLEFT JOIN technicians AS s  ON s.technician_id = e.supervisor_idORDER BY e.technician_id;

Mina has no supervisor, so the left join preserves her with a NULL supervisor name. This same adjacency-list structure becomes the recursive CTE hierarchy in Lesson 4.

Observe optimizer evidence, but keep cardinality logic separate

sql · compare join plan and result count
EXPLAIN FORMAT=TREESELECT w.work_order_id, c.customer_nameFROM work_orders AS wJOIN customers AS c  ON c.customer_id = w.customer_idWHERE w.status='open';SELECT COUNT(*) AS result_rowsFROM work_orders AS wJOIN customers AS c  ON c.customer_id = w.customer_idWHERE w.status='open';

The plan may show indexed lookups, table scans, or hash joins depending on the current schema and optimizer. That does not replace the semantic reasoning: every qualifying work order has exactly one customer, so the join should not multiply those rows.

sql · collect actual execution evidence locally
EXPLAIN ANALYZESELECT w.work_order_id, c.customer_nameFROM work_orders AS wJOIN customers AS c  ON c.customer_id = w.customer_idWHERE w.status='open';

EXPLAIN ANALYZE executes the statement and reports observed iterator rows/timing in addition to estimates. Do not copy a fixed timing into documentation as a universal benchmark: record the learner’s own output, compare estimated versus actual row counts, and investigate large estimation errors later in the optimizer chapters.

Hands-on lab: predict before execute

  1. Write down the expected row count before each of these: work-orders/customers inner join, technicians inner join, technicians left join, tags inner join, and the full work-orders × technicians product.
  2. Execute each count and reconcile any mismatch before continuing.
  3. Demonstrate how WHERE t.active=TRUE after a left join removes the unassigned row.
  4. Move the same condition into ON and explain the changed business meaning.
  5. Join work orders 1001 and 1004 to tags and explain why each appears twice.
  6. Run the self join and identify the root technician whose supervisor is NULL.

Knowledge check

  1. Why does an inner join from work_orders to technicians return fewer rows than a left join in this dataset?
  2. Why can moving t.active=TRUE from ON to WHERE change a LEFT JOIN result?
  3. What is the expected cardinality of 7 work orders CROSS JOIN 5 technicians?
  4. Why is SELECT DISTINCT a dangerous first response to unexpected join duplicates?
  5. What do aliases e and s represent in the technician self join?
Reveal answers
  1. Work order 1003 has no technician_id, so it has no equality match and is removed by the inner join; a left join preserves it with NULL right-side columns.
  2. WHERE is evaluated on the joined result and rejects NULL-extended rows; a right-side predicate in ON instead controls whether a right row matches while preserving the left row.
  3. 35 row combinations before any filter.
  4. It can hide a missing/incorrect join predicate or an misunderstood one-to-many relationship without fixing the underlying semantics or excess work.
  5. They are two logical roles for the same technicians table: employee and supervisor.

Production judgment and next bridge

For production joins, review keys and relationship multiplicities together with the query. Track estimated versus observed row counts when performance becomes important, but first verify the business cardinality. A fast wrong join is still wrong. Be especially cautious when outer joins, optional relationships, and multiple child tables meet in one report.

The next lesson asks whether a relationship is better expressed as a nested query: scalar lookup, existence test, membership test, correlated calculation, or derived table. MySQL’s optimizer may transform some of those forms, but their SQL semantics must be understood before comparing plans.

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 assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.