Joins and Set OperationsIntroductionA relational database stores information in related tables rather than one large duplicated table. A customer’s name may be stored in customers, while that customer’s orders are stored in orders. SQL joins combine rows from these tables according to a logical relationship. Set operations combine the results of compatible queries. Joins are central to application development, reporting, analytics, and data integration. However, an incorrect join can silently duplicate rows, omit important records, or produce misleading business conclusions. This chapter develops the theory and practice of inner, outer, cross, and self joins, then examines UNION, INTERSECT, and EXCEPT. Learning ObjectivesBy the end of this chapter, you should be able to: - Explain how join predicates determine which rows are combined.
- Write inner, left, right, full outer, cross, and self joins.
- Distinguish one-to-one, one-to-many, and many-to-many relationships.
- Diagnose unexpected row multiplication and missing rows.
- Use
UNION, INTERSECT, and EXCEPT when combining compatible result sets. - Select an appropriate operation for practical database tasks.
1. Relational FoundationsA relation is conceptually a set of tuples (rows) described by attributes (columns). In practical SQL systems, tables may contain duplicate rows unless uniqueness is enforced, so SQL is often described as operating on multisets or bags. This distinction matters: UNION removes duplicates by default, whereas UNION ALL preserves them. Consider the following simplified schema: CREATE TABLE departments (
department_id INTEGER PRIMARY KEY,
department_name VARCHAR(100) NOT NULL
);
CREATE TABLE employees (
employee_id INTEGER PRIMARY KEY,
employee_name VARCHAR(100) NOT NULL,
department_id INTEGER,
manager_id INTEGER,
salary DECIMAL(10,2),
FOREIGN KEY (department_id) REFERENCES departments(department_id),
FOREIGN KEY (manager_id) REFERENCES employees(employee_id)
);
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
order_date DATE NOT NULL,
total_amount DECIMAL(10,2),
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);A primary key identifies a row uniquely. A foreign key refers to a candidate key in another table. A foreign key may be nullable unless declared NOT NULL; therefore, the schema and business rules must be examined before predicting join results. Join terminology- Join key: column or expression used to match rows.
- Join predicate: Boolean condition defining a match.
- Matching row: row satisfying the predicate.
- Preserved table: table whose rows remain in an outer join even without a match.
- Cardinality: number of rows produced or relationship multiplicity.
- Equijoin: join using equality, such as
a.id = b.id. - Non-equijoin: join using another comparison, such as
< or BETWEEN.
2. Inner JoinsAn inner join returns only combinations satisfying its join condition. The explicit JOIN ... ON form is preferred because it separates relationship logic from filtering logic. SELECT c.customer_id, c.customer_name, o.order_id, o.total_amount
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id;A customer with no orders is absent from this result. An order whose customer_id does not match a customer is also absent, although a valid foreign key normally prevents the latter. The same relationship can be written using USING when both columns have the same name: SELECT c.customer_id, c.customer_name, o.order_id
FROM customers AS c
JOIN orders AS o USING (customer_id); USING is not available in every database system, so ON is more portable.
Filtering in ON and WHEREFor an inner join, these forms often produce the same result: SELECT c.customer_name, o.order_id
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.total_amount >= 100; SELECT c.customer_name, o.order_id
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.total_amount >= 100; The distinction becomes important with outer joins, because a WHERE condition applied to a null-extended row can remove that row. 3. Outer JoinsOuter joins preserve rows that do not find a match. Missing columns from the other table are represented by NULL. Left outer joinA left join preserves every row from the table on the left: SELECT c.customer_id, c.customer_name, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id;This is suitable for finding customers with and without orders: SELECT c.customer_id, c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;To count orders accurately, use a nullable-side column such as o.order_id, not COUNT(*): SELECT c.customer_id, c.customer_name,
COUNT(o.order_id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.customer_name;COUNT(o.order_id) counts only non-null order identifiers, whereas COUNT(*) counts the preserved customer row even when no order exists.
Right outer joinA right join preserves every row from the right table: SELECT d.department_name, e.employee_name
FROM employees AS e
RIGHT JOIN departments AS d
ON e.department_id = d.department_id;A right join can usually be rewritten as a left join by reversing table order. Many teams prefer left joins because they are easier to read consistently. Full outer joinA full join preserves unmatched rows from both tables: SELECT d.department_name, e.employee_name
FROM departments AS d
FULL OUTER JOIN employees AS e
ON e.department_id = d.department_id;It is useful for reconciliation, such as comparing two systems. Some database products do not support FULL OUTER JOIN directly; an equivalent design may require a union of a left join and a right-only join, with care to avoid duplicates. Outer-join predicate placementSuppose the objective is to list every customer and show only orders of at least 100 monetary units. The condition belongs in ON: SELECT c.customer_name, o.order_id, o.total_amount
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.total_amount >= 100; If o.total_amount >= 100 is placed in WHERE, customers with no qualifying order are removed. Thus, the first query preserves the left-table population; the second behaves like an inner join for that condition. 4. Cross JoinsA cross join produces the Cartesian product: every row of one input is paired with every row of the other. SELECT d.department_name, s.shift_name
FROM departments AS d
CROSS JOIN shifts AS s; If the first input has m rows and the second has n rows, the result has m × n row combinations, assuming ordinary bag semantics and no later filter. For 6 departments and 3 shifts: 6 × 3 = 18 A cross join is appropriate for generating all possible combinations, such as products and sizes, dates and stores, or departments and scheduled shifts. It is dangerous when caused accidentally by omitting a join predicate. 5. Self JoinsA self join joins a table to itself using aliases. It is useful for hierarchical or comparative relationships. The displayed employees schema includes both department_id and manager_id, so the following query is valid: SELECT e.employee_name AS employee,
m.employee_name AS manager
FROM employees AS e
LEFT JOIN employees AS m
ON e.manager_id = m.employee_id;The employee alias e represents the subordinate row, while m represents the manager row. The left join keeps top-level employees whose manager_id is null. A self join can also compare employees in the same department: SELECT e1.employee_name AS employee_1,
e2.employee_name AS employee_2,
e1.department_id
FROM employees AS e1
JOIN employees AS e2
ON e1.department_id = e2.department_id
AND e1.employee_id < e2.employee_id;The inequality prevents pairing an employee with themself and ensures that each unordered pair appears once rather than twice. This query is valid because department_id is explicitly present in the schema. For deep organizational hierarchies, repeated self joins become impractical. Recursive common table expressions are generally more suitable: WITH RECURSIVE org AS (
SELECT employee_id, employee_name, manager_id, 0 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, e.employee_name, e.manager_id, org.level + 1
FROM employees AS e
JOIN org ON e.manager_id = org.employee_id
)
SELECT * FROM org;Syntax for recursive queries varies across database systems. 6. Join Cardinality and Row MultiplicationJoin cardinality describes how many output rows can result from matching input rows. The relationship type provides useful bounds, but the exact result depends on data and predicates. For a primary-key-to-foreign-key join, each child row matches at most one parent row. Therefore, joining 450 orders to customers by orders.customer_id = customers.customer_id, when customer_id is a primary key in customers, produces at most 450 matching rows. It produces exactly 450 if every order has a matching customer, as required by a valid non-null foreign key. It cannot exceed 450 under these stated conditions. The general one-to-many pattern is: parent row → zero, one, or many child rows A customer with 4 orders contributes 4 rows to a customer–order inner join. A customer with no order contributes no row to that inner join but contributes one null-extended row to a left join. Many-to-many multiplicationSuppose 3 students are enrolled in 4 modules and the enrollment table contains 12 distinct student-module pairs. Joining students to enrollments and then modules produces 12 enrollment rows. If a query joins two independent one-to-many child tables directly, multiplication can occur. For example, a customer with 3 orders and 2 support tickets may produce 3 × 2 = 6 rows when both child tables are joined to the customer in one query. A safer design is to aggregate each child table first: SELECT c.customer_id,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(t.ticket_count, 0) AS ticket_count
FROM customers AS c
LEFT JOIN (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) AS o ON o.customer_id = c.customer_id
LEFT JOIN (
SELECT customer_id, COUNT(*) AS ticket_count
FROM support_tickets
GROUP BY customer_id
) AS t ON t.customer_id = c.customer_id;Diagnostic techniques- Identify the intended grain: one row per customer, order, or customer-order pair?
- Check uniqueness of join columns.
- Count rows before and after each join.
- Inspect duplicate keys with
GROUP BY ... HAVING COUNT(*) > 1. - Use
COUNT(DISTINCT key) only when deduplication reflects the business meaning; it should not conceal an incorrect join. - Check execution plans and indexes for performance, while remembering that an index does not correct logical duplication.
7. Set OperationsSet operations combine complete query results vertically. The participating queries must have the same number of columns, in compatible positions and data types. Column names in the final result normally come from the first query. UNIONUNION concatenates results and removes duplicate rows:
SELECT email FROM current_customers
UNION
SELECT email FROM archived_customers; If the first result has 80 distinct emails, the second has 50, and 20 appear in both, the result has: 80 + 50 − 20 = 110 distinct rows, assuming each input is already duplicate-free. UNION ALL retains duplicates and usually requires less work:
SELECT email FROM current_customers
UNION ALL
SELECT email FROM archived_customers; Use UNION ALL when duplicate occurrences are meaningful or when sources are known to be mutually exclusive. INTERSECTINTERSECT returns rows present in both results:
SELECT customer_id FROM customers_2024
INTERSECT
SELECT customer_id FROM customers_2025; It answers a membership question: which customers occur in both populations? Duplicate handling follows the database’s set-operation rules; standard INTERSECT returns distinct rows. Some systems also support INTERSECT ALL. EXCEPTEXCEPT returns rows in the first result that are absent from the second:
SELECT customer_id FROM customers_2024
EXCEPT
SELECT customer_id FROM customers_2025; The order matters. Reversing the queries asks for customers in 2025 but not 2024. Oracle commonly uses the equivalent keyword MINUS rather than EXCEPT. Set operations versus joinsA join combines columns horizontally according to relationships. A set operation combines rows vertically according to result compatibility. | Requirement | Appropriate operation |
|---|
| Add customer columns to order rows | Join | | Combine two lists of compatible values | UNION or UNION ALL | | Find values common to two lists | INTERSECT | | Find values in one list but not another | EXCEPT | | Generate every possible pairing | CROSS JOIN |
Parentheses should be used when mixing set operators or applying ordering and limiting. An ORDER BY generally applies to the final combined result: (
SELECT customer_id FROM customers_2024
UNION
SELECT customer_id FROM customers_2025
)
ORDER BY customer_id;8. Worked Application: Retail Data ReconciliationBackgroundThis illustrative case concerns a retailer with a live customer table and a legacy customer export. The data team must identify active customers who also appear in the legacy file, find legacy-only customers, and report every active customer with any orders. ProblemThe tables are: customers(customer_id, customer_name, email)orders(order_id, customer_id, order_date, total_amount)legacy_customers(customer_id, email)
The team initially writes a single query joining orders and legacy customers directly to customers. The output contains repeated customers because customers may have multiple orders and multiple legacy records. AnalysisThe tasks have different relational meanings: - Customer details plus order details require a join.
- Membership in both customer populations requires
INTERSECT. - Legacy-only identifiers require
EXCEPT. - A customer-level report should aggregate orders before joining if one row per customer is required.
SolutionCommon active and legacy identifiers: SELECT customer_id FROM customers
INTERSECT
SELECT customer_id FROM legacy_customers; Legacy-only identifiers: SELECT customer_id FROM legacy_customers
EXCEPT
SELECT customer_id FROM customers; One row per active customer with order totals: SELECT c.customer_id,
c.customer_name,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(o.total_spend, 0.00) AS total_spend
FROM customers AS c
LEFT JOIN (
SELECT customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_spend
FROM orders
GROUP BY customer_id
) AS o ON o.customer_id = c.customer_id;Outcome and learning pointsThe revised design preserves customers with no orders, avoids order-level duplication in the customer report, and uses set operations for population comparison. The central lesson is to define the required grain before choosing an operation. 9. Performance, Integrity, and GovernanceJoin performance depends on table size, indexes, statistics, predicates, data distribution, and the optimizer. Indexes on primary and foreign keys commonly support joins, but indexing every column can increase storage and update cost. Select only required columns, filter early where logically safe, and inspect execution plans for large queries. Constraints improve correctness. Primary keys prevent duplicate identifiers; foreign keys protect referential integrity; NOT NULL expresses required relationships; and unique constraints protect alternate identifiers such as email addresses. Without constraints, assumptions about cardinality may be false. Null values require special care. NULL = NULL is not true in SQL’s three-valued logic; it evaluates to unknown. Use IS NULL or IS NOT NULL. Do not replace nulls with arbitrary values merely to force a match, because that can create false relationships. Access control also matters. A join may combine personally identifiable information with transactions. Apply least privilege, mask sensitive columns, audit access, and follow applicable privacy and retention requirements. Set operations used for reconciliation should be documented so that analysts can explain why records were included or excluded. Modern query engines may use hash joins, merge joins, or nested-loop joins. These are implementation strategies chosen by the optimizer; SQL authors should focus first on correct semantics and accurate constraints. AI-assisted SQL tools can suggest joins, but generated queries require human review of keys, null behavior, cardinality, privacy, and business meaning. Best Practices- State the intended grain before writing the query.
- Use explicit
JOIN ... ON syntax. - Qualify columns with aliases.
- Verify that join keys have the expected uniqueness.
- Use left joins deliberately when unmatched left rows matter.
- Put outer-join-preserving filters in
ON when appropriate. - Use
COUNT(column) rather than COUNT(*) when counting matched child rows. - Prefer
UNION ALL when deduplication is neither required nor meaningful. - Use parentheses when combining set operations.
- Test empty, duplicate, null, and unmatched cases.
- Validate output counts against known constraints and business expectations.
Chapter SummaryJoins combine columns from related tables, while set operations combine compatible query results. Inner joins retain matching combinations; outer joins preserve selected unmatched rows with null-extended columns; cross joins generate Cartesian products; and self joins use aliases to compare or navigate rows within one table. Join cardinality follows key constraints and relationship multiplicity. A primary-key-to-foreign-key join from 450 orders to unique customers cannot produce more than 450 rows and produces 450 when all orders have valid customers. Independent one-to-many joins can multiply rows, so aggregation or carefully designed subqueries may be necessary. UNION removes duplicates, UNION ALL preserves them, INTERSECT finds common rows, and EXCEPT finds rows present only in the first query. Correct SQL depends on keys, null semantics, intended grain, integrity constraints, and responsible handling of sensitive data. Key TermsJoin: Operation that combines rows from tables using a predicate. Join predicate: Boolean condition that determines whether rows match. Inner join: Join returning only matching row combinations. Outer join: Join preserving unmatched rows from one or both inputs. Left join: Outer join preserving every row from the left input. Right join: Outer join preserving every row from the right input. Full outer join: Join preserving unmatched rows from both inputs. Cross join: Cartesian product of two inputs. Self join: A table joined to itself through aliases. Join key: Column or expression used to relate rows. Cardinality: Number of rows or multiplicity of a relationship. One-to-many: Relationship in which one parent can have many children. Cartesian product: All possible pairings of two row sets. Null extension: Filling missing outer-join columns with NULL. UNION: Combines compatible results and removes duplicate rows. UNION ALL: Combines compatible results while retaining duplicates. INTERSECT: Returns rows common to both results. EXCEPT: Returns rows in the first result but not the second. Grain: The real-world entity represented by one output row. Referential integrity: Consistency between foreign-key values and referenced keys. Review QuestionsShort Answer Questions- What is the difference between an inner join and a left outer join?
- Why can an accidental missing join predicate create a Cartesian product?
- What role do aliases play in a self join?
- Why might
COUNT(o.order_id) be preferable to COUNT(*) in a left join? - State the compatibility requirements for queries combined with a set operation.
Long Answer Questions- Explain one-to-one, one-to-many, and many-to-many relationships, using SQL examples.
- Compare
UNION, UNION ALL, INTERSECT, and EXCEPT, including duplicate behavior and typical uses. - Explain how placing a filter in
ON rather than WHERE affects a left outer join. - Discuss causes of join row multiplication and describe a systematic debugging approach.
- Explain how primary keys, foreign keys, nullability, and unique constraints affect join-cardinality reasoning.
Analytical / Application Questions- A report should contain one row per customer, including customers without orders, with order count and total spending. Design a query strategy that avoids duplication and justify each operation.
- A table contains 600 orders, and every order has a non-null foreign key referencing a unique customer identifier. What is the maximum possible number of rows in an inner order-customer join? Explain the assumptions.
- A data auditor must find identifiers appearing in the current customer population but not in a legacy extract, then investigate records appearing in both. Choose suitable set operations and explain why a join is not the most direct first choice.
Multiple Choice Questions with AnswersQuestion 1Which query preserves every customer, including those with no orders? A. SELECT * FROM customers c INNER JOIN orders o ON o.customer_id = c.customer_id B. SELECT * FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id C. SELECT * FROM customers c CROSS JOIN orders o D. SELECT * FROM customers c RIGHT JOIN orders o ON o.customer_id = c.customer_id Correct Answer: B. SELECT * FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id Question 2Under a valid non-null foreign key from 450 orders to unique customer IDs, what is the maximum number of rows in the inner order-customer join? A. 225 B. 450 C. 900 D. The number of customers squared Correct Answer: B. 450 Question 3What does a cross join produce? A. Only unmatched rows B. Only rows with equal keys C. Every possible pairing of rows from the two inputs D. Only duplicate-free rows Correct Answer: C. Every possible pairing of rows from the two inputs Question 4Why is the condition e1.employee_id < e2.employee_id useful in a same-department employee self join? A. It removes all employees without managers B. It prevents self-pairs and reverse duplicate pairs C. It converts the join into a cross join D. It requires employee IDs to be foreign keys Correct Answer: B. It prevents self-pairs and reverse duplicate pairs Question 5Which operation returns rows present in both query results? A. UNION B. EXCEPT C. INTERSECT D. CROSS JOIN Correct Answer: C. INTERSECT Question 6Which operation preserves duplicate rows when combining compatible query results? A. UNION B. UNION ALL C. INTERSECT D. EXCEPT Correct Answer: B. UNION ALL Question 7A customer has 3 orders and 2 support tickets. Joining both child tables directly to that customer can produce how many combinations for that customer? A. 2 B. 3 C. 5 D. 6 Correct Answer: D. 6 Question 8Which clause is generally appropriate when a left join must preserve customers while matching only orders of at least 100? A. Put the amount condition in the ON clause B. Put the amount condition only in SELECT C. Replace the join with a cross join D. Put the condition in a GROUP BY clause Correct Answer: A. Put the amount condition in the ON clause Question 9What does EXCEPT return in a query A EXCEPT query B? A. Rows common to A and B B. All rows from A and B including duplicates C. Rows in A that are absent from B D. Rows in B that are absent from A Correct Answer: C. Rows in A that are absent from B Question 10Which statement about set operations is correct? A. The queries may return different numbers of columns B. Corresponding columns should have compatible data types C. Set operations always combine columns horizontally D. EXCEPT is unaffected by operand order Correct Answer: B. Corresponding columns should have compatible data types
|