Day 9: Set operations
On this page
By the end of this lesson you can
- Combine result sets with
UNION,UNION ALL,INTERSECTandEXCEPT, and predict their row counts. - Apply the rules for column count, data types, column names and
ORDER BYin set operations. - Explain operator precedence (
INTERSECTbeforeUNION/EXCEPT) and use parentheses to control it. - Explain how set operators treat NULLs (as equal) and duplicates (removed), and how that differs from
IN,NOT INandEXISTS. - Use
EXCEPTin both directions to compare two data sets, including multi-column comparisons for change detection. - Convert untrusted text with
TRY_CAST, which returns NULL instead of raising an error. - Choose
UNION ALLoverUNIONwhen duplicates are impossible or wanted.
A. Lesson overview
| Week | 2 — Joins, set operations and advanced retrieval |
| Level | Intermediate |
| Estimated time | 2 h 15 min essential + 30 min optional stretch |
| Prerequisites | Days 1–8, especially EXISTS/NOT EXISTS (Day 3) and outer joins (Day 8) |
| Syllabus topic | Set Operations — UNION, UNION ALL, INTERSECT, EXCEPT |
| Foundations added today | TRY_CAST for safe conversion of untrusted text |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Rows as sets: the four operators | 20 min |
| Part 2 — Rules: columns, types, names, ordering, precedence | 15 min |
Part 3 — NULLs and duplicates: set operators vs EXISTS/IN |
15 min |
Part 4 — Comparing data sets and TRY_CAST |
10 min |
| Worked examples | 25 min |
| Guided lab | 25 min |
| Independent practice | 20 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. A new batch of web and in-store sign-ups has arrived in Staging.CustomerImport, and Merchandising has submitted price changes in Staging.ProductPriceUpdate. Before anything is loaded, the data team must answer: Which import emails are new? Which belong to existing customers, and have their details changed? Which prices would actually change? Comparing whole sets of rows is what set operators do best.
B. Theory explained clearly
Part 1 — Rows as sets
A query result is a set of rows. Set operators combine two complete queries vertically (rows on top of rows), whereas joins combine tables horizontally (columns side by side).
| Operator | Returns | Duplicates in result |
|---|---|---|
A UNION B |
Rows in A or B | Removed |
A UNION ALL B |
Every row of A followed by every row of B | Kept |
A INTERSECT B |
Distinct rows in both A and B | Removed |
A EXCEPT B |
Distinct rows in A that are not in B | Removed |
EXCEPT is not symmetric: A EXCEPT B and B EXCEPT A are different questions. SQL Server does not support INTERSECT ALL or EXCEPT ALL.
USE RetailLab;
GO
SELECT City FROM Sales.Customer
UNION
SELECT City FROM Sales.Store
ORDER BY City; -- 32 distinct cities
UNION versus UNION ALL
UNION must find and remove duplicates, which means sorting or hashing every row. UNION ALL simply appends. If the two inputs cannot overlap (for example, different years) or you want every row (an activity feed), use UNION ALL: it is cheaper and states your intent. A common anti-pattern is writing UNION by habit and silently losing legitimate duplicate rows — two orders of the same amount on the same day, for instance.
Part 2 — Rules
| Rule | Detail |
|---|---|
| Same number of columns | Each query must return the same number of columns |
| Compatible types, by position | Columns are matched by position, not by name. Types must be implicitly convertible; the result uses data type precedence (e.g. int with decimal → decimal) |
| Column names from the first query | Aliases in the second and later queries are ignored |
One ORDER BY, at the end |
It sorts the combined result and may only use first-query column names or aliases |
TOP, OFFSET |
Apply to an individual query only if it is wrapped in parentheses or a derived table |
Collation. Text comparisons in UNION, INTERSECT and EXCEPT use the column collation. In RetailLab (case-insensitive), 'BEN.CARTER@EXAMPLE.COM' and 'ben.carter@example.com' are the same row. On a case-sensitive database they would be different. Trailing spaces are ignored in comparisons; leading spaces are not — use TRIM.
Precedence. INTERSECT is evaluated before UNION and EXCEPT; UNION and EXCEPT have equal precedence and run left to right.
SELECT n FROM (VALUES (1), (2)) AS A(n)
UNION
SELECT n FROM (VALUES (2), (3)) AS B(n)
INTERSECT
SELECT n FROM (VALUES (3), (4)) AS C(n); -- B INTERSECT C = {3}; A UNION {3} = {1, 2, 3}
Wrap the first two in parentheses — (SELECT … UNION SELECT …) INTERSECT SELECT … — and the result is {3}. As with AND/OR, add parentheses whenever you mix operators.
Part 3 — NULLs and duplicates
Set operators compare rows for distinctness, and two NULLs are considered not distinct — the same. This differs from = comparisons:
| Technique | NULL vs NULL | Duplicates in left input |
|---|---|---|
A INTERSECT B |
Match | Removed |
A EXCEPT B |
Match (so a NULL in A is removed if B has a NULL) | Removed |
WHERE x IN (SELECT …) / EXISTS (… = …) |
No match | Kept |
WHERE x NOT IN (SELECT …) |
Whole condition becomes UNKNOWN if the list contains NULL | Kept |
NOT EXISTS (… WHERE a.x = b.x) |
No match, so a NULL in A is returned | Kept |
Consequences:
- Multi-column comparisons are easy with
EXCEPT.SELECT Email, Phone, City FROM a EXCEPT SELECT Email, Phone, City FROM bcorrectly treats two NULL phones as equal. Writing the same withNOT EXISTSneeds(a.Phone = b.Phone OR (a.Phone IS NULL AND b.Phone IS NULL))for every nullable column. EXCEPTreturns distinct rows only. If you need every matching row (with duplicates) or columns that are not being compared, useNOT EXISTS.
SQL Server 2022 and later also support the predicate
IS [NOT] DISTINCT FROM, which compares two values treating NULLs as equal — useful in join andWHEREconditions. The course uses it in Week 3.
Part 4 — Comparing data sets and TRY_CAST
Two-way EXCEPT is a standard data-quality check: if A EXCEPT B and B EXCEPT A both return no rows, the two sets are identical (as sets). This is how you prove that a rewritten query returns exactly the same result as the original.
TRY_CAST(expression AS type) converts like CAST, but returns NULL when conversion fails instead of raising an error:
SELECT TRY_CAST('62.50' AS decimal(10,2)) AS Good, -- 62.50
TRY_CAST('N/A' AS decimal(10,2)) AS Bad, -- NULL
CAST('N/A' AS decimal(10,2)) AS Fails; -- error 8114: Error converting data type varchar to numeric.
Use it whenever you read text from staging tables or files. TRY_CONVERT is the equivalent of CONVERT.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | Sales.Customer, Sales.Store, Sales.SalesOrder, Sales.SalesOrderLine, Sales.Payment, Inventory.Product, HR.Employee, Staging.CustomerImport, Staging.ProductPriceUpdate |
| Teaching cases used | Emails that differ only by case or leading spaces; NULL emails on both sides; a non-numeric price (N/A), a negative price and an unknown product code in the price updates |
| Permissions | SELECT on these tables |
| Objects created today | None — Day 9 is read-only |
Preparation. None if RetailLab is unchanged. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive) or scripts 00 → 03.
Validation. Expected: 15, 10, 4, 1.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Staging.CustomerImport) AS ImportRows,
(SELECT COUNT(*) FROM Staging.ProductPriceUpdate) AS PriceRows,
(SELECT COUNT(*) FROM Sales.Customer WHERE Email IS NULL) AS CustomersWithoutEmail,
(SELECT COUNT(*) FROM Staging.CustomerImport WHERE Email IS NULL) AS ImportRowsWithoutEmail;
D. Worked examples
Worked example 1 — UNION, UNION ALL and INTERSECT on cities
SELECT COUNT(*) AS UnionAllRows
FROM (SELECT City FROM Sales.Customer UNION ALL SELECT City FROM Sales.Store) AS x;
SELECT COUNT(*) AS UnionRows
FROM (SELECT City FROM Sales.Customer UNION SELECT City FROM Sales.Store) AS x;
SELECT City FROM Sales.Customer
INTERSECT
SELECT City FROM Sales.Store
ORDER BY City;
Expected result: 43 (40 customers + 3 stores), 32 distinct cities, and 3 cities with both customers and a store: Edinburgh, London, Manchester.
Worked example 2 — Products sold in one year but not the other
-- Sold in 2024 but not in 2025
SELECT l.ProductID
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate < '2025-01-01'
EXCEPT
SELECT l.ProductID
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01';
-- Sold in 2025 but not in 2024
SELECT l.ProductID
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01'
EXCEPT
SELECT l.ProductID
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate < '2025-01-01';
Expected result: product 30 (the bell tent, discontinued at the end of 2024) and product 28 (the Kids Daypack, launched May 2025). INTERSECT of the same two queries returns 27 products sold in both years. Notice there is no DISTINCT anywhere: the operators removed the hundreds of duplicate product IDs.
Worked example 3 — Customers who use both channels
SELECT CustomerID FROM Sales.SalesOrder WHERE SalesChannel = 'Online'
INTERSECT
SELECT CustomerID FROM Sales.SalesOrder WHERE SalesChannel = 'Store';
Expected result: 33 customers. Swap INTERSECT for EXCEPT in both directions: customer 35 only orders online, customer 32 only in store.
Worked example 4 — NULLs are “equal” to set operators
SELECT TRIM(Email) AS Email FROM Staging.CustomerImport
INTERSECT
SELECT Email FROM Sales.Customer
ORDER BY Email;
Expected result: 4 rows: NULL, then the emails of Alice Morgan, Ben Carter (in whichever case SQL Server returns from the first query) and Mohammed Rahman.
The NULL row appears because both sides contain a NULL email, and set operators treat NULLs as not distinct. Compare with Day 8’s join on c.Email = TRIM(i.Email), which matched only 3 rows. For an email match, the NULL “match” is meaningless, so filter it out: add WHERE Email IS NOT NULL to either query.
Worked example 5 — Precedence
SELECT n FROM (VALUES (1), (2)) AS A(n)
UNION
SELECT n FROM (VALUES (2), (3)) AS B(n)
INTERSECT
SELECT n FROM (VALUES (3), (4)) AS C(n)
ORDER BY n;
(SELECT n FROM (VALUES (1), (2)) AS A(n)
UNION
SELECT n FROM (VALUES (2), (3)) AS B(n))
INTERSECT
SELECT n FROM (VALUES (3), (4)) AS C(n)
ORDER BY n;
Expected result: 1, 2, 3 and then 3.
Worked example 6 — A combined activity feed (UNION ALL)
WITH OrderEvents AS
(
SELECT 'Order' AS EventType, o.OrderDate AS EventDate, o.OrderID,
ROUND(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)), 2) + o.ShippingCost AS Amount
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = 1 AND o.OrderDate >= '2025-11-01'
GROUP BY o.OrderDate, o.OrderID, o.ShippingCost
)
SELECT EventType, EventDate, OrderID, CAST(Amount AS decimal(10,2)) AS Amount
FROM OrderEvents
UNION ALL
SELECT 'Payment', p.PaymentDate, p.OrderID, p.Amount
FROM Sales.Payment AS p
JOIN Sales.SalesOrder AS o ON o.OrderID = p.OrderID
WHERE o.CustomerID = 1 AND p.PaymentDate >= '2025-11-01'
ORDER BY EventDate, EventType, OrderID;
Expected result: 8 rows: an Order and a Payment on 24 November (1346, 33.98), 30 November (1349, 328.95) and 3 December (1350, 615.60), then two unpaid orders: 1372 (147.59) and 1373 (683.10).
What to notice: the column names come from the first query; the second query’s 'Payment' literal needs no alias. UNION ALL is correct — an order and its payment with the same amount and date differ only by EventType, but other events could be identical and should all appear. The CAST keeps the two Amount types aligned for readability; SQL Server would otherwise choose the higher-precedence decimal type automatically.
Worked example 7 — TRY_CAST on staging prices
SELECT RowID,
ProductCode,
NewListPriceText,
TRY_CAST(NewListPriceText AS decimal(10,2)) AS NewListPrice,
CASE
WHEN TRY_CAST(NewListPriceText AS decimal(10,2)) IS NULL THEN 'Not a number'
WHEN TRY_CAST(NewListPriceText AS decimal(10,2)) <= 0 THEN 'Not positive'
ELSE 'OK'
END AS PriceCheck
FROM Staging.ProductPriceUpdate
ORDER BY RowID;
Expected result: 10 rows. Row 5 (N/A) is Not a number; row 8 (-14.99) is Not positive; the rest are OK. Replace TRY_CAST with CAST and the query fails on row 5 — all or nothing.
E. Guided practical lab — Classify the customer import
Data engineering wants the import classified before loading: which emails are new, which belong to existing customers, which existing customers have changed details, and which import rows can’t be matched at all.
Step 1 — New emails (EXCEPT)
- Do: Find cleaned import emails that don’t exist in
Sales.Customer. Exclude NULLs explicitly. - Run:
USE RetailLab; GO SELECT TRIM(Email) AS Email FROM Staging.CustomerImport WHERE Email IS NOT NULL EXCEPT SELECT Email FROM Sales.Customer ORDER BY Email; - Observe: 8 distinct emails:
blank.name@example.com,daisy.holt@example.com,emma.wilson@example.com,ines.moreau@example.fr,kofi.mensah@example.com,no.name@example.com,sam.jones@andtariq.aziz@example. - Why it matters: Daisy Holt’s three rows (two different spellings) and Kofi Mensah’s two rows collapsed into one email each:
EXCEPTde-duplicates, and the collation ignores case. Two of the eight are obviously invalid email addresses — validation is a separate step (Day 13). - Confirm: 8 rows.
Step 2 — Existing customers (INTERSECT)
- Do: Find import emails that already belong to customers.
- Run:
SELECT TRIM(Email) AS Email FROM Staging.CustomerImport WHERE Email IS NOT NULL INTERSECT SELECT Email FROM Sales.Customer ORDER BY Email; - Observe: 3 rows — Alice Morgan, Ben Carter, Mohammed Rahman.
- Why it matters: Without
WHERE Email IS NOT NULL, a fourth “match” (NULL) would appear, as in worked example 4. - Confirm: 3 + 8 = 11 distinct non-NULL import emails.
Step 3 — Which existing customers’ details differ? (multi-column EXCEPT)
-
Do: For matched emails, compare email, phone, city and country between the import and the customer table.
-
Run:
SELECT TRIM(i.Email) AS Email, i.Phone, i.City, i.Country FROM Staging.CustomerImport AS i WHERE EXISTS (SELECT 1 FROM Sales.Customer AS c WHERE c.Email = TRIM(i.Email)) EXCEPT SELECT Email, Phone, City, Country FROM Sales.Customer ORDER BY Email; -
Observe: 2 rows:
Email Phone City Country alice.morgan@example.com +44 20 7946 0999 London United Kingdom BEN.CARTER@EXAMPLE.COM +44 161 496 0102 Manchester UK -
Why it matters: Alice has a new phone number; Ben’s country is written
UKinstead ofUnited Kingdom. Mohammed Rahman’s row is identical and drops out. The email case difference for Ben is ignored (case-insensitive collation) — only the country differs. Set comparison found field-level changes without a singleOR … IS NULLtest. -
Confirm: 2 rows. Explain in one sentence why Ben’s row appears.
Step 4 — Rows that cannot be matched by email
- Do: List import rows with no usable email.
- Run:
SELECT ImportRowID, FullName, Email FROM Staging.CustomerImport WHERE Email IS NULL ORDER BY ImportRowID; - Observe: 1 row — row 12, Lily Chen.
- Why it matters: Every import row now falls into exactly one bucket. Rows 1, 2 and 11 are existing customers; row 12 needs manual review; the other 11 rows carry the 8 new emails.
- Confirm: 3 + 1 + 11 = 15 import rows.
Step 5 — One classification result (UNION ALL)
- Do: Combine the classifications into one labelled list of emails.
- Run:
SELECT 'New' AS Classification, Email FROM (SELECT TRIM(Email) AS Email FROM Staging.CustomerImport WHERE Email IS NOT NULL EXCEPT SELECT Email FROM Sales.Customer) AS n UNION ALL SELECT 'Existing', Email FROM (SELECT TRIM(Email) AS Email FROM Staging.CustomerImport WHERE Email IS NOT NULL INTERSECT SELECT Email FROM Sales.Customer) AS e ORDER BY Classification, Email; - Observe: 11 rows: 3 Existing, 8 New.
- Why it matters:
UNION ALLis right here because the two inputs cannot overlap — an email is either in the customer table or not. Each set operation sits inside a derived table, so the outerUNION ALLcombines finished sets. - Confirm: 11 rows.
F. Independent practice
Task 1 — Not yet sold in Edinburgh
List active products (no DiscontinuedDate) that have never been sold in the Edinburgh store (StoreID = 3). Return ProductID and ProductCode.
Success criteria: 3 rows — 5 SLP-002, 28 BPK-003, 29 NAV-004.
Hint
Both queries must return the same two columns. The second query needs the product code too, so join to Inventory.Product (or compare on ProductID alone and join for the code afterwards).
Task 2 — Countries without a shop
Return the countries where Ridgeway has customers but no store, sorted alphabetically.
Success criteria: 8 countries, from Denmark to United States. The United Kingdom does not appear.
Task 3 — Salespeople who are also managers
Return the EmployeeID of anyone who has served at least one store order and manages at least one employee. Then return the same set with EXISTS, and compare.
Success criteria: 1 employee — 6 (Liam O’Connor). Both techniques agree.
Hint
HR.Employee.ManagerID contains a NULL. Does that affect INTERSECT? Does it affect an IN version?
Task 4 — Customer 1’s changing basket (challenging)
For customer 1 (non-cancelled orders), classify every product they bought in 2024 or 2025 as Repeat (both years), Dropped (2024 only) or New (2025 only). Return Classification and ProductID in one result, sorted by classification and product.
Success criteria: 27 rows — 10 Repeat, 9 Dropped (including product 30, the discontinued bell tent) and 8 New.
Hint
Define the two product sets once in CTEs (P2024, P2025), then UNION ALL three set operations, each wrapped in a derived table with its label.
Stretch challenge — Which prices would change?
Build the proposed price list from Staging.ProductPriceUpdate: clean the code (UPPER(TRIM(…))), keep the latest row per code (Day 5), keep only prices that convert to a positive number, and only codes that exist in Inventory.Product. Then use set operators to show the before/after pairs for products whose price would actually change: ProductCode, ListPrice as Before rows and as After rows.
Success criteria: 10 rows (5 Before, 5 After) for COK-003, JKT-005, LGT-001, SLP-003 and TNT-001. BPK-001 (N/A), NAV-003 (negative) and TNT-099 (unknown code) are excluded.
Hint
Current EXCEPT Proposed gives the Before rows; Proposed EXCEPT Current gives the After rows. Restrict Current to the codes that have a valid proposal, or every unchanged product will appear as Before.
G. Solutions and explanations
Show solution 1
SELECT ProductID, ProductCode
FROM Inventory.Product
WHERE DiscontinuedDate IS NULL
EXCEPT
SELECT p.ProductID, p.ProductCode
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
WHERE o.StoreID = 3
ORDER BY ProductID;
Result: 5 SLP-002, 28 BPK-003, 29 NAV-004.
Reasoning. The second query’s join to Inventory.Product is only there to supply the code. A NOT EXISTS version (WHERE NOT EXISTS (SELECT 1 FROM … WHERE l.ProductID = p.ProductID AND o.StoreID = 3)) is equally correct and would let you return extra columns, such as the product name, without including them in the comparison.
Show solution 2
SELECT Country FROM Sales.Customer
EXCEPT
SELECT Country FROM Sales.Store
ORDER BY Country;
Result: Denmark, France, Germany, Ireland, Italy, Spain, Sweden, United States.
Show solution 3
SELECT EmployeeID FROM Sales.SalesOrder WHERE EmployeeID IS NOT NULL
INTERSECT
SELECT ManagerID FROM HR.Employee;
SELECT e.EmployeeID
FROM HR.Employee AS e
WHERE EXISTS (SELECT 1 FROM Sales.SalesOrder AS o WHERE o.EmployeeID = e.EmployeeID)
AND EXISTS (SELECT 1 FROM HR.Employee AS r WHERE r.ManagerID = e.EmployeeID)
ORDER BY e.EmployeeID;
Result: both return 6.
Reasoning. Both sides of the INTERSECT contain NULLs (online orders have no salesperson; Eleanor has no manager). Without the WHERE EmployeeID IS NOT NULL filter, INTERSECT would also return a NULL row, because NULLs are treated as equal. EXISTS with = never matches NULLs, so it needs no such filter.
Show solution 4
WITH P2024 AS
(
SELECT l.ProductID
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = 1 AND o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2024-01-01' AND o.OrderDate < '2025-01-01'
),
P2025 AS
(
SELECT l.ProductID
FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = 1 AND o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
)
SELECT 'Repeat' AS Classification, ProductID FROM (SELECT ProductID FROM P2024 INTERSECT SELECT ProductID FROM P2025) AS r
UNION ALL
SELECT 'Dropped', ProductID FROM (SELECT ProductID FROM P2024 EXCEPT SELECT ProductID FROM P2025) AS d
UNION ALL
SELECT 'New', ProductID FROM (SELECT ProductID FROM P2025 EXCEPT SELECT ProductID FROM P2024) AS n
ORDER BY Classification, ProductID;
Result: 27 rows. Dropped: 7, 10, 11, 16, 17, 22, 23, 25, 30. New: 3, 4, 12, 13, 14, 20, 24, 26. Repeat: 1, 2, 5, 6, 9, 15, 18, 19, 21, 27.
Reasoning. Each CTE is referenced twice — CTEs are a natural partner for set operations. The three groups are disjoint, so UNION ALL is correct and cheaper than UNION. Without the derived tables, precedence would apply INTERSECT before EXCEPT and the labels would not line up with the rows.
Show the stretch solution
WITH Latest AS
(
SELECT UPPER(TRIM(ProductCode)) AS ProductCode,
TRY_CAST(NewListPriceText AS decimal(10,2)) AS NewListPrice,
ROW_NUMBER() OVER (PARTITION BY UPPER(TRIM(ProductCode)) ORDER BY RowID DESC) AS rn
FROM Staging.ProductPriceUpdate
WHERE ProductCode IS NOT NULL
),
Proposed AS
(
SELECT l.ProductCode, l.NewListPrice AS ListPrice
FROM Latest AS l
WHERE l.rn = 1
AND l.NewListPrice > 0 -- NULL (not a number) fails this too
AND EXISTS (SELECT 1 FROM Inventory.Product AS p WHERE p.ProductCode = l.ProductCode)
),
CurrentPrices AS
(
SELECT p.ProductCode, p.ListPrice
FROM Inventory.Product AS p
WHERE p.ProductCode IN (SELECT ProductCode FROM Proposed)
)
SELECT 'Before' AS Version, ProductCode, ListPrice
FROM (SELECT ProductCode, ListPrice FROM CurrentPrices
EXCEPT
SELECT ProductCode, ListPrice FROM Proposed) AS b
UNION ALL
SELECT 'After', ProductCode, ListPrice
FROM (SELECT ProductCode, ListPrice FROM Proposed
EXCEPT
SELECT ProductCode, ListPrice FROM CurrentPrices) AS a
ORDER BY ProductCode, Version DESC; -- Before, then After
| Version | ProductCode | ListPrice |
|---|---|---|
| Before | COK-003 | 6.50 |
| After | COK-003 | 6.75 |
| Before | JKT-005 | 99.00 |
| After | JKT-005 | 104.00 |
| Before | LGT-001 | 39.00 |
| After | LGT-001 | 42.00 |
| Before | SLP-003 | 59.00 |
| After | SLP-003 | 62.50 |
| Before | TNT-001 | 189.00 |
| After | TNT-001 | 195.00 |
Reasoning. The rule “latest row per code” is applied before validation, so LGT-001’s latest update (42.00) wins over the earlier 41.00. If the latest row had been invalid, you would have to decide whether to fall back to the previous valid one — an important business rule to agree. On Day 15 you will apply these changes with UPDATE and MERGE, and use exactly this comparison to check the result.
H. Knowledge check
-
Understanding.
Ahas 10 rows (2 duplicates),Bhas 5 rows, and 3 distinct rows appear in both. How many rows doA UNION ALL BandA INTERSECT Breturn? -
Query interpretation. What does this return?
SELECT CAST(NULL AS int) AS x EXCEPT SELECT CAST(NULL AS int); -
Debugging. This fails. Why?
SELECT CustomerID, City FROM Sales.Customer UNION SELECT StoreID FROM Sales.Store; -
Debugging. A developer writes
ORDER BY StoreNameat the end ofSELECT City AS Place FROM Sales.Customer UNION SELECT StoreName FROM Sales.Store. It fails with Invalid column name. Why, and what should they write? -
Understanding. When would you choose
NOT EXISTSinstead ofEXCEPTto find rows in A that are not in B?
Show the answers
-
UNION ALL: 15 rows (10 + 5, duplicates kept).INTERSECT: 3 rows (distinct rows present in both). -
No rows. Set operators treat the two NULLs as the same value, so the NULL in the first query is removed by the second. (
WHERE NULL NOT IN (NULL)would also return nothing, but for a different reason: UNKNOWN.) -
The queries return different numbers of columns (2 and 1). Every query in a set operation must return the same number of columns, matched by position.
-
Column names come from the first query, so the combined result’s column is called
Place. WriteORDER BY Place. -
When you need to return columns that are not part of the comparison (e.g. compare on email but show the name), when duplicates in A must be kept, or when NULL should not count as a match.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists. | Different column counts | Align the SELECT lists |
| Conversion failed when converting… / Error converting data type… | Columns at the same position have incompatible types | CAST explicitly to a common type |
Invalid column name in the final ORDER BY |
Using a second-query name or a table-qualified name | Use the first query’s column name or alias |
| Incorrect syntax near the keyword ‘ORDER’ | ORDER BY placed in the first query |
One ORDER BY, at the very end |
Unexpected NULL row in an INTERSECT |
NULLs treated as equal | Filter IS NOT NULL in one input |
Rows lost after UNION |
UNION removed legitimate duplicates |
Use UNION ALL |
| Case/space variants treated as different | Case-sensitive collation, or leading spaces | TRIM; LOWER/UPPER or a COLLATE clause on a case-sensitive database |
| Error converting data type varchar to numeric. (8114) | CAST on untrusted text |
TRY_CAST and handle NULLs |
Cleanup. Day 9 is read-only; nothing to clean up. Keep RetailLab unchanged for Day 10.
J. Completion checklist, recap and references
Completion checklist
- Worked example 1 returned 43, 32 and 3 rows.
- Worked example 4 showed a NULL “match” and you know how to remove it.
- The lab classified all 15 import rows (3 existing, 1 without email, 11 rows carrying 8 new emails) and found 2 changed customers.
- Tasks 1–4 match the success criteria (3, 8, 1 and 27 rows).
- You can explain when
UNION ALLis the correct choice.
Recap
UNION(distinct),UNION ALL(everything),INTERSECT(in both, distinct),EXCEPT(in first not second, distinct).- Same column count; compatible types by position; names from the first query; one
ORDER BYat the end. INTERSECTbinds tighter thanUNION/EXCEPT; parenthesise mixed operators.- Set operators treat NULLs as equal and remove duplicates — unlike
=,INandEXISTS. - Two-way
EXCEPTproves two results identical; multi-columnEXCEPTdetects changed rows. TRY_CASTreturns NULL instead of failing on bad text.
Next: Day 10 — The APPLY operator: CROSS APPLY, OUTER APPLY and table-valued functions.