Practice13problems on this pattern, easy to hardStart practising →
Pattern 9SQL course / Combining Data

SQL set operations: combining and comparing whole query results

Learn how UNION ALL, UNION, INTERSECT and EXCEPT stack and compare whole rows, and how column choice, column order, NULLs and operator precedence change what they return.

26 min read15 sections10 predictions13 practice problems
01

Combining results from two tables

The shop example we are using has recently moved to a new order management system. During the migration, some orders were copied from the old system to the new one.

The new system stores orders in the orders table, which we used in P07.

orders7 rows
order_idcustomer_idproductamountstatusorder_date
10011Wireless Mouse1500paid2026-05-02
10022Mechanical Keyboard3200pending2026-05-03
10033Laptop Sleeve3600paid2026-05-03
10044Office Chair8500cancelled2026-05-04
10051Wireless Mouse750paid2026-05-06
10062Monitor Stand3600paid2026-05-07
1007NULLMechanical Keyboard3200paid2026-05-08

The old system’s data was exported into a separate table called legacy_orders.

legacy_orders4 rows
order_idcustomer_idproductamountstatusorder_date
9983Laptop Sleeve1200paid2026-04-20
999NULLWireless Mouse750paid2026-04-25
10011Wireless Mouse1500paid2026-05-02
10044Office Chair8500paid2026-05-04

Notice three important details:

  • Orders 998 and 999 exist only in the old system. Order 999 is a guest order with no customer ID.
  • Orders 1001 and 1004 appear in both tables because they were migrated.
  • Order 1004 has different statuses in the two systems. It was marked paid in the old system but was later cancelled in the new system.

Finance now needs reports that combine data from both systems. For example:

  • A complete list of orders from both systems.
  • The total order value without counting migrated orders twice.
  • Orders that appear in both systems.
  • Orders whose details differ between the two systems.

These requirements are different from the joins we studied in P07. A JOIN combines related rows from two tables, typically adding columns side by side. Here, we need to combine or compare rows returned by separate queries.

SQL provides four set operations for this:

  • UNION ALL
  • UNION
  • INTERSECT
  • EXCEPT

Each operation takes the results of two queries and combines or compares their rows. The examples in this lesson use DuckDB. Where another database behaves differently, we will point it out.

02

UNION ALL: Combining all rows

The first requirement is to create one list containing every order from both systems, including orders that appear in both tables. We can use UNION ALL to combine the results of two queries. Both queries must return the same number of columns, with compatible data types in corresponding positions. The final ORDER BY sorts the combined result.

Pause and predictPredict before you continue

How many rows will this query return?

The query
SELECT order_id, product, amount
FROM orders
UNION ALL
SELECT order_id, product, amount
FROM legacy_orders
ORDER BY order_id;
03

UNION: Removing duplicate rows

UNION also combines the results of two queries, but unlike UNION ALL, it removes duplicate rows.

Two rows are considered duplicates when all their selected column values are the same. Consider a simpler requirement: Find all distinct products sold across both systems.

Pause and predictPredict before you continue

The orders table has 7 rows, and legacy_orders has 4. How many rows will this query return?

The query
SELECT product
FROM orders
UNION
SELECT product
FROM legacy_orders
ORDER BY product;

What happens when we combine orders?

Now let’s use UNION to combine the order details, just as we did in section 02.

Query
SELECT order_id, product, amount
FROM orders
UNION
SELECT order_id, product, amount
FROM legacy_orders
ORDER BY order_id;
Result9 rows
order_idproductamount
998Laptop Sleeve1200
999Wireless Mouse750
1001Wireless Mouse1500
1002Mechanical Keyboard3200
1003Laptop Sleeve3600
1004Office Chair8500
1005Wireless Mouse750
1006Monitor Stand3600
1007Mechanical Keyboard3200

The result now contains 9 rows instead of 11.

Orders 1001 and 1004 each appeared twice in the UNION ALL result. Since their selected values are identical across both tables, UNION keeps only one copy of each. Notice that order 1004 has different statuses in the two systems. However, status is not part of the query, so it does not affect duplicate detection. This leads to an important rule: UNION compares only the columns returned by the queries.

If two rows have identical values in every selected column, UNION treats them as duplicates, even if they represent different records in the original tables.

04

Calculating totals from combined results

Finance now wants to calculate the total value of all orders across both systems, counting each order only once.

We know that UNION removes duplicate rows, so it seems like a reasonable choice. To keep the query simple, someone selects only the columns needed for the report: product and amount.

Pause and predictPredict before you continue

How many rows will this query return?

The query
SELECT product, amount
FROM orders
UNION
SELECT product, amount
FROM legacy_orders
ORDER BY product, amount;

How does this affect the total?

Suppose we calculate the total amount from the combined result:

Query
SELECT SUM(amount) AS total_amount
FROM (
SELECT product, amount FROM orders
UNION
SELECT product, amount FROM legacy_orders
) AS combined;
Result1 row
total_amount
22350

The total is incorrect because two valid orders were removed. Their combined value is:

3,200 + 750 = 3,950

So the reported total is 3,950 lower than it should be.

Why not use UNION ALL?

We might try replacing UNION with UNION ALL to keep those orders.

Query
SELECT SUM(amount) AS total_amount
FROM (
SELECT order_id, amount FROM orders
UNION ALL
SELECT order_id, amount FROM legacy_orders
) AS combined;
Result1 row
total_amount
36300

This produces a different problem. UNION ALL keeps every row, including the migrated copies of orders 1001 and 1004. Their amounts, 1,500 and 8,500, are counted twice, adding an extra 10,000 to the total.

Therefore:

  • UNION without order_id removes some valid orders.
  • UNION ALL counts migrated orders more than once.

Neither query gives the correct total.

The fix: Include the order ID

To count each order once, we need to distinguish orders that happen to share the same product and amount.

Include order_id in both queries:

Query
SELECT SUM(amount) AS total_amount
FROM (
SELECT order_id, product, amount FROM orders
UNION
SELECT order_id, product, amount FROM legacy_orders
) AS combined;
Result1 row
total_amount
26300

The result is now correct.

Here’s what changes:

  • Orders 1001 and 1004 each appear only once because their selected values match across both systems.
  • Orders 1002 and 1007 remain separate because they have different order IDs, even though their product and amount are identical.
  • Orders 999 and 1005 also remain separate for the same reason.

The combined result contains 9 distinct orders, and each contributes to the total once.

Comparing the three approaches

ApproachTotal amountProblem
UNION using product, amount22,350Removes different orders with identical product and amount.
UNION ALL using order_id, amount36,300Counts migrated orders twice.
UNION using order_id, product, amount26,300Correct for this dataset.

One important limitation remains: this solution works because the migrated copies have identical selected values in both systems. If the amount or product changes for the same order, UNION will keep both versions because their rows are no longer identical. We will explore how to handle such cases in sections 07 and 11.

05

Column order matters in set operations

The team wants a combined list of order IDs and customer IDs from both systems. However, the second query lists the columns in a different order. Both columns contain integers, so the mistake may not be obvious.

Pause and predictPredict before you continue

What happens when this query runs?

The query
SELECT order_id, customer_id
FROM orders
UNION ALL
SELECT customer_id, order_id
FROM legacy_orders
ORDER BY order_id;

Three rules for combining query results

When using set operations, remember these rules:

  1. Both queries must return the same number of columns. A mismatch produces an error.
  2. Columns in corresponding positions must have compatible data types. Depending on the database, incompatible types may cause an error or trigger an automatic conversion.
  3. Columns in corresponding positions should represent the same information. SQL does not check this for you.

The combined result also takes its column names from the first query.

DuckDB supports UNION BY NAME and UNION ALL BY NAME, which match columns by name instead of position. However, these are not standard features available in every database.

06

Sorting and limiting combined results

The team wants to combine the two largest orders from the current system with every order from the legacy system.

Someone writes a query that sorts and limits the current orders before adding the legacy orders with UNION ALL.

Pause and predictPredict before you continue

What happens when this query runs?

The query
SELECT order_id, amount
FROM orders
ORDER BY amount DESC, order_id
LIMIT 2
UNION ALL
SELECT order_id, amount
FROM legacy_orders;

The fix: Limit the first query separately

In DuckDB, we can use parentheses to give the first query its own ORDER BY and LIMIT.

Query
(
SELECT order_id, amount
FROM orders
ORDER BY amount DESC, order_id
LIMIT 2
)
UNION ALL
(
SELECT order_id, amount
FROM legacy_orders
)
ORDER BY amount DESC, order_id;
Result6 rows
order_idamount
10048500
10048500
10033600
10011500
9981200
999750

The result contains 6 rows, as intended. Here’s how the query works:

  1. The first query sorts current orders by amount and keeps the top two: orders 1004 and 1003.
  2. The second query returns all four legacy orders.
  3. UNION ALL combines these results without removing duplicates.
  4. The final ORDER BY sorts all six rows by amount.

Notice that order 1004 appears twice because it exists in both systems and UNION ALL keeps both copies.

Why include order_id in ORDER BY?

Two current orders, 1003 and 1006, both have an amount of 3,600. If we sorted only by amount, either order could be selected as the second-largest order. Adding order_id makes the result predictable. When amounts are equal, the smaller order ID comes first. Therefore, order 1003 is selected instead of 1006.

What if ORDER BY and LIMIT appear only at the end?

Consider this query:

Query
SELECT order_id, amount
FROM orders
UNION ALL
SELECT order_id, amount
FROM legacy_orders
ORDER BY amount DESC, order_id
LIMIT 2;
Result2 rows
order_idamount
10048500
10048500

Here, SQL combines all 11 rows first, then sorts them and keeps only the top two. Both rows belong to order 1004, one from each system. This is different from selecting the top two current orders before combining them with the legacy orders.

Also, the final ORDER BY in the earlier corrected query sorts all six rows together. If the report must display current orders before legacy orders, we would need an additional source column to control that ordering.

Database note: DuckDB and PostgreSQL support parenthesised query branches.

07

Tracking where each row came from

After combining rows from two systems, we may need to know which system each row came from.

A simple solution is to add a column containing a fixed value such as 'current' or 'legacy'. Someone adds this column and uses UNION, expecting it to remove orders that appear in both systems.

Pause and predictPredict before you continue

How many rows will this query return?

The query
SELECT order_id, 'current' AS source
FROM orders
UNION
SELECT order_id, 'legacy' AS source
FROM legacy_orders
ORDER BY order_id, source;

How do we keep one copy of each order?

The requirement has changed: Create one combined order list, but when an order exists in both systems, keep the current system’s version.

This is important because the current system contains the latest information. For example, order 1004 is marked cancelled in the current system but paid in the legacy system. UNION cannot decide which version is more recent or which system should take priority. It only compares selected values. Instead, we need to define which source wins.

Our approach is:

  1. Keep every order from the current system.
  2. Add orders from the legacy system only when their order_id does not exist in the current system.

We can use NOT EXISTS, as learned in P08, to exclude migrated legacy orders.

Query
SELECT order_id, 'current' AS source
FROM orders
UNION ALL
SELECT l.order_id, 'legacy' AS source
FROM legacy_orders AS l
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.order_id = l.order_id
)
ORDER BY order_id;
Result9 rows
order_idsource
998legacy
999legacy
1001current
1002current
1003current
1004current
1005current
1006current
1007current

The result now contains 9 orders, one row per order.

Orders 1001 and 1004 come only from the current system because their legacy copies are excluded by NOT EXISTS. Orders 998 and 999 are included from the legacy system because they do not exist in the current table.

Why use UNION ALL instead of UNION?

We have already removed overlapping orders using NOT EXISTS. Since order_id is unique within each source in this example, the two query results now contain no duplicate order records. There is no need for UNION to perform another duplicate-removal step. UNION ALL simply combines the remaining rows.

08

Combine first, then aggregate

Finance now wants a report showing the number of orders and total order amount for each customer across both systems.

Each order must be counted only once, even if it appears in both tables. We already solved the duplication problem in section 07 by keeping all current orders and adding only legacy orders that do not exist in the current system.

We can use that same approach here.

The steps are:

  1. Build one combined result containing each order exactly once.
  2. Group that result by customer_id.
  3. Calculate the order count and total amount for each customer.

To do this, place the combined query inside a derived table, then use GROUP BY in the outer query.

Query
SELECT
customer_id,
COUNT(*) AS orders,
SUM(amount) AS total_amount
FROM (
SELECT order_id, customer_id, amount
FROM orders
UNION ALL
SELECT l.order_id, l.customer_id, l.amount
FROM legacy_orders AS l
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.order_id = l.order_id
)
) AS all_orders
GROUP BY customer_id
ORDER BY customer_id;
Result5 rows
customer_idorderstotal_amount
122250
226800
324800
418500
NULL23950

Understanding the result

Let’s check a few customers against the original tables.

Customer 1 (Asha) has two orders:

  • Order 1001: 1,500
  • Order 1005: 750

Order 1001 also exists in legacy_orders, but its legacy copy is excluded. Therefore, Asha has 2 orders with a total amount of 2,250.

Customer 3 (Carla) also has two orders:

  • Order 1003: 3,600 from the current system.
  • Order 998: 1,200 from the legacy system.

Since order 998 does not exist in the current system, it is included. Carla’s total is 4,800.

What about the NULL customer_id?

The result also contains a group where customer_id is NULL.

This group contains two guest orders:

  • Order 1007: 3,200
  • Order 999: 750

GROUP BY places rows with NULL values in the grouping column into the same group. Therefore, the guest orders produce one row with 2 orders and a total amount of 3,950.

Why combine before aggregating?

If we calculate totals separately in orders and legacy_orders, the same customer may appear in both results. We would then need another aggregation to combine those totals. More importantly, the migrated orders 1001 and 1004 would still be counted twice unless we removed their legacy copies first.

By combining the order records before aggregating, we ensure that each order contributes to the totals only once. Notice that the derived table includes order_id, even though the outer query does not display it. Keeping this key makes the intended level of detail clear: one row represents one order. It also prevents distinct orders with identical customer IDs and amounts from being mistaken for duplicate rows if the combination method changes.

Be careful when filtering by status

The current query includes orders of every status, including pending and cancelled orders. For example, customer 4’s total of 8,500 comes from cancelled order 1004.

Suppose Finance wants to count only paid orders. A safer approach is to include status in the combined result and apply WHERE status = 'paid' in the outer query, before GROUP BY.

Why not filter each source separately before combining? Consider order 1004:

  • The current system marks it cancelled.
  • The legacy system still marks it paid.

If we filter the current table to paid orders before checking which legacy orders already exist, order 1004 may no longer be present to exclude its legacy copy. The old paid record could then enter the combined result, producing an incorrect paid-order total.

Tip
When calculating totals across multiple sources, first build a combined result with one row per business record. Then apply the required filters and aggregate the result. This avoids counting migrated records more than once.
09

INTERSECT: Finding rows in both results

So far, we have used UNION and UNION ALL to combine rows from two queries. INTERSECT works differently. It returns only the distinct rows that appear in both query results.

Suppose the migration team wants to find customer IDs that have orders in both the current and legacy systems.

Pause and predictPredict before you continue

What will this query return?

The query
SELECT customer_id
FROM orders
INTERSECT
SELECT customer_id
FROM legacy_orders
ORDER BY customer_id;

How is this different from INNER JOIN?

We could also find customers appearing in both systems using a join:

Query
SELECT DISTINCT o.customer_id
FROM orders AS o
INNER JOIN legacy_orders AS l
ON l.customer_id = o.customer_id
ORDER BY o.customer_id;
Result3 rows
customer_id
1
3
4

Notice that this result does not contain NULL. That is because the join condition uses ordinary equality:

Query
l.customer_id = o.customer_id

For guest orders, both customer IDs are NULL. Since NULL = NULL does not evaluate to TRUE, the rows do not match. The join also needs DISTINCT because a customer can have multiple orders, producing repeated matches. INTERSECT, on the other hand, automatically returns each matching row only once.

Which result is correct?

It depends on what the data represents. In our example, NULL means that the order was placed by a guest whose customer ID is unknown. Two guest orders with NULL customer IDs do not necessarily belong to the same customer. Therefore, including NULL would be misleading if the report is supposed to identify known customers who ordered in both systems.

To exclude guest orders, filter out NULL customer IDs from both queries:

Query
SELECT customer_id
FROM orders
WHERE customer_id IS NOT NULL
INTERSECT
SELECT customer_id
FROM legacy_orders
WHERE customer_id IS NOT NULL
ORDER BY customer_id;
Result3 rows
customer_id
1
3
4

Now the result contains only known customer IDs present in both systems. However, there are cases where treating two NULL values as matching is useful, such as comparing records where NULL represents the same missing or unspecified category. The important point is to understand what NULL means in your data before deciding whether to include it or not.

10

EXCEPT: Finding rows missing from another result

EXCEPT returns rows from the first query that do not appear in the second query. It also removes duplicates, returning each distinct row only once.

Let’s use the customers table from P07.

customers5 rows
customer_idcustomer_namecitycountryreferred_by_customer_id
1AshaBengaluruIndiaNULL
2BenLondonUK1
3CarlaDubaiUAE1
4DevBengaluruIndia2
5EshaSingaporeSingaporeNULL

The requirement is to find customers who have never placed an order in the current system.

We can use EXCEPT to subtract customer IDs found in orders from all customer IDs.

Query
SELECT customer_id
FROM customers
EXCEPT
SELECT customer_id
FROM orders
ORDER BY customer_id;
Result1 row
customer_id
5

The result contains only Esha, whose customer ID is 5.

Here’s how it works:

  1. The first query returns all customer IDs: 1, 2, 3, 4, 5.
  2. The second query returns customer IDs from orders.
  3. EXCEPT removes IDs that appear in the second result.

Only customer 5 remains because Esha has never placed an order.

What happens to NULL values?

Remember that guest order 1007 has a NULL customer_id. In P08, we saw that a NULL inside a NOT IN subquery could cause the query to return no rows. EXCEPT behaves differently. It compares rows using the same rules discussed with INTERSECT: two NULL values are treated as matching. A NULL in the second result only removes a NULL from the first result, if one exists. It does not affect other values. Therefore, the guest order’s NULL does not prevent Esha from appearing.

What happens if we reverse the queries?

Unlike UNION and INTERSECT, the order of the queries matters with EXCEPT.

Consider:

Query
SELECT customer_id
FROM orders
EXCEPT
SELECT customer_id
FROM customers
ORDER BY customer_id;
Result1 row
customer_id
NULL

This query asks a different question: Which customer IDs appear in orders but not in the customers table?

The result is NULL because the current orders contain a guest order without a customer ID, while customers has no NULL customer ID. Every non-NULL customer ID from orders exists in customers, so those IDs are removed. The direction of the comparison changes the answer.

Finding products missing from the legacy system

Suppose we want to find products sold in the current system that were never sold in the legacy system.

Query
SELECT product
FROM orders
EXCEPT
SELECT product
FROM legacy_orders
ORDER BY product;
Result2 rows
product
Mechanical Keyboard
Monitor Stand

These products appear in orders but not in legacy_orders. Notice that EXCEPT returns each product only once, even when the current system contains multiple orders for that product.

What if we want to compare how many times each product appears, rather than just whether it appears?

SQL provides EXCEPT ALL, which takes duplicate rows into account.

Pause and predictPredict before you continue

What will this query return?

The query
SELECT product
FROM orders
EXCEPT ALL
SELECT product
FROM legacy_orders
ORDER BY product;

When should we use the ALL forms?

The ALL forms are useful when duplicate counts matter, such as comparing two exports to verify that they contain the same rows the same number of times. For example, ordinary EXCEPT would find no difference between a table containing one copy of an order and another containing two identical copies. EXCEPT ALL can detect that difference. To check equality in both directions, run the comparison from A to B and from B to A.

Database support: DuckDB, PostgreSQL and MySQL 8.0.31 or later support INTERSECT ALL and EXCEPT ALL. SQL Server and SQLite support the distinct forms but not their ALL variants.

11

Finding changed rows

Before retiring the legacy system, the migration team wants to identify orders whose status has changed between the old and current systems.

We know that EXCEPT compares every selected column. An analyst uses it to compare each order’s ID and status across the two tables.

Pause and predictPredict before you continue

What will this query return?

The query
SELECT order_id, status
FROM legacy_orders
EXCEPT
SELECT order_id, status
FROM orders
ORDER BY order_id;

Finding only orders with changed statuses

If we want only orders that exist in both systems but have different statuses, we should first restrict the legacy query to order IDs present in the current system.

Query
SELECT order_id, status
FROM legacy_orders
WHERE order_id IN (
SELECT order_id
FROM orders
)
EXCEPT
SELECT order_id, status
FROM orders
ORDER BY order_id;
Result1 row
order_idstatus
1004paid

Now orders 998 and 999 are excluded because their IDs do not exist in orders. Order 1004 remains because its legacy status differs from the current status. Notice that the WHERE clause applies only to the first query.

Showing the old and new values

EXCEPT tells us that a row differs, but it does not show both versions side by side. For a report showing the actual changes, a join is often easier to understand.

Query
SELECT
l.order_id,
l.status AS legacy_status,
o.status AS current_status
FROM legacy_orders AS l
INNER JOIN orders AS o
ON o.order_id = l.order_id
WHERE l.status IS DISTINCT FROM o.status
ORDER BY l.order_id;
Result1 row
order_idlegacy_statuscurrent_status
1004paidcancelled

The INNER JOIN matches orders by ID, while IS DISTINCT FROM keeps rows whose statuses differ. We use IS DISTINCT FROM instead of <> because it handles NULL values correctly. For example, a change from NULL to 'paid' is detected as a difference.

Choosing EXCEPT vs JOIN?

Both approaches are useful, but they answer slightly different questions.

  • EXCEPT is convenient when we want to find rows whose selected values are missing or different between two datasets.
  • JOIN is more useful when we need to display the old and new values side by side.

For a table with many columns, EXCEPT can compare all selected columns without writing a separate comparison for each one. A join would require explicitly checking the columns we want to compare. We can also combine both approaches: use EXCEPT to identify changed records, then join those records back to their original tables to retrieve the details.

12

Mixing set operations

So far, we have used one set operator at a time.

Real queries may combine UNION, INTERSECT and EXCEPT in the same statement. When this happens, the order in which SQL evaluates the operators becomes important.

Consider a request from Marketing: Find customers from India who have either a paid order in the current system or any order in the legacy system.

An analyst writes three queries:

  1. Customers with paid current orders.
  2. Customers with legacy orders.
  3. Customers from India.

Then combines them using UNION and INTERSECT.

Pause and predictPredict before you continue

What will this query return?

The query
SELECT customer_id
FROM orders
WHERE status = 'paid'
UNION
SELECT customer_id
FROM legacy_orders
INTERSECT
SELECT customer_id
FROM customers
WHERE country = 'India'
ORDER BY customer_id;

Why does this happen?

In DuckDB, INTERSECT has higher precedence than UNION. This means SQL evaluates the INTERSECT portion first, even though UNION appears earlier in the statement. The query is interpreted as:

All customers with paid current orders
UNION
Customers who appear in legacy orders AND are from India

Let’s look at the two parts.

  • The paid current orders contain customer IDs: 1, 2, 3, NULL
  • The legacy orders contain: 1, 3, 4, NULL
  • The customers from India are: 1, 4
  • The INTERSECT between legacy customers and Indian customers gives: 1, 4

Finally, UNION combines those values with the paid current customers: 1, 2, 3, 4, NULL

The problem is that the country condition was applied only to the legacy customers, not to the entire combined result.

The fix: Use parentheses

The requirement is to first combine both groups of customers and then keep only those from India. We can make that order explicit using parentheses:

Query
(
SELECT customer_id
FROM orders
WHERE status = 'paid'
UNION
SELECT customer_id
FROM legacy_orders
)
INTERSECT
SELECT customer_id
FROM customers
WHERE country = 'India'
ORDER BY customer_id;
Result2 rows
customer_id
1
4

The result now contains only Asha and Dev.

Parentheses tell SQL to:

  1. Combine customers with paid current orders and customers with legacy orders.
  2. Compare the combined result with customers from India.
  3. Keep only IDs that appear in both results.

The query now matches the requirement.

Another problem: Combining two EXCEPT results

Suppose we want to find every order record that differs between the legacy and current systems. We need two comparisons:

  • Rows in legacy_orders but not in orders.
  • Rows in orders but not in legacy_orders.

Someone combines both comparisons without parentheses:

Query
SELECT order_id, status
FROM legacy_orders
EXCEPT
SELECT order_id, status
FROM orders
UNION ALL
SELECT order_id, status
FROM orders
EXCEPT
SELECT order_id, status
FROM legacy_orders
ORDER BY order_id;
Result6 rows
order_idstatus
1002pending
1003paid
1004cancelled
1005paid
1006paid
1007paid

The result contains only rows unique to the current system. The legacy-only rows have disappeared. Why?

For this query, the operators are evaluated from left to right. SQL first finds the legacy-only rows, then adds all current rows using UNION ALL. But the final EXCEPT removes rows that also exist in the legacy table. That removes the legacy-only rows we initially wanted to keep, along with matching current rows. The result contains only the current-side differences.

The fix: Group each comparison separately

We can use parentheses to keep the two EXCEPT operations independent, then combine their results. Let’s also add a side column to show where each unmatched row came from.

Query
(
SELECT
order_id,
status,
'legacy only' AS side
FROM legacy_orders
EXCEPT
SELECT
order_id,
status,
'legacy only'
FROM orders
)
UNION ALL
(
SELECT
order_id,
status,
'current only'
FROM orders
EXCEPT
SELECT
order_id,
status,
'current only'
FROM legacy_orders
)
ORDER BY order_id, side;
Result9 rows
order_idstatusside
998paidlegacy only
999paidlegacy only
1002pendingcurrent only
1003paidcurrent only
1004cancelledcurrent only
1004paidlegacy only
1005paidcurrent only
1006paidcurrent only
1007paidcurrent only

Now both comparisons are preserved. Notice that order 1004 appears twice:

  • (1004, 'paid') exists only as that combination in the legacy system.
  • (1004, 'cancelled') exists only as that combination in the current system.

This tells us that order 1004 exists in both systems but has different statuses. Order 1001 does not appear because its selected values match across both tables.

Why use the same source label inside each EXCEPT? Both queries within each EXCEPT must use the same label. Otherwise, identical orders would have different side values and would no longer be considered duplicates. The label is added only after deciding which comparison each row belongs to.

Does every database use the same precedence?

No. Set-operator precedence can differ between SQL engines. DuckDB, PostgreSQL, SQL Server and MySQL give INTERSECT higher precedence than UNION and EXCEPT. SQLite and some Oracle versions apply different precedence rules. For this reason, it is better to use parentheses whenever a statement contains different kinds of set operations, rather than relying on an engine’s default evaluation order.

13

Troubleshooting set-operation mistakes

Set-operation queries often run successfully even when they produce incorrect results. You might see totals that are too high or too low, unexpected duplicate rows, missing records, or values appearing in the wrong columns.

Use this table to identify common problems:

What you seeWhat to checkSection
The total is higher than expectedUNION ALL may be counting records that exist in both tables more than once.02, 04, 07
The total is lower than expectedUNION may be removing different records that have identical selected values.04
Values appear under the wrong column namesThe queries may list their columns in different orders.05
A numeric column unexpectedly contains textThe database may have converted different column types into a common type.05
A syntax error occurs near UNIONORDER BY or LIMIT may have been placed inside a query without the required grouping.06
The same order appears twice in a top-N resultUNION ALL kept both copies before the final LIMIT was applied.06
UNION does not remove expected duplicatesA source label or another selected column may make otherwise matching rows different.07
A filter affects only some of the combined dataThe WHERE condition may have been applied to only one query.08
INTERSECT returns a NULL row that an equivalent join does notSet operations treat NULL values as matching for duplicate comparison.09
EXCEPT returns rows from the wrong sourceThe first and second queries may be reversed.10
Two tables appear identical, but their row counts differOrdinary EXCEPT ignores duplicate counts. Use EXCEPT ALL when counts matter.10
A report of changed records includes missing recordsEXCEPT finds missing rows as well as rows whose selected values differ.11
A mixed set-operation query ignores a filter or loses part of the resultOperator precedence may be changing how the queries are grouped.12
The query behaves differently in another databaseSQL engines differ in type conversion, operator support and precedence rules.05, 10, 12

Three checks before trusting the result

When a set-operation query produces unexpected results, start with these checks.

  1. Compare row counts. For UNION ALL, the result should contain the sum of the input row counts. For UNION, check which rows were removed and why.
  2. Check the selected columns. Every query must return the same number of columns, with the same meaning in corresponding positions. Remember that the first query determines the result’s column names.
  3. Check where sorting, limiting and filtering happen. Confirm whether each WHERE, ORDER BY and LIMIT applies to one query or the combined result. Use parentheses when mixing different set operators.
14

Choosing between set operations, JOIN and EXISTS

We have now learned three ways to work with data from related tables.

Each is suited to a different type of question.

  • Set operations combine or compare complete query results.
  • JOIN connects matching rows and can return columns from both tables.
  • EXISTS and NOT EXISTS check whether matching rows exist without multiplying the outer rows.

The best choice depends on what the requirement asks us to return.

Which approach should you use?

RequirementRecommended approachWhy
Combine every row from two query resultsUNION ALLKeeps all rows, including duplicates.
Combine results and remove identical rowsUNIONReturns distinct rows based on the selected columns.
Combine two sources, keeping one preferred version per keyUNION ALL with NOT EXISTSLets us choose which source takes priority.
Find values present in both resultsINTERSECTReturns distinct rows found in both results.
Find values present in one result but not anotherEXCEPTReturns distinct rows missing from the second result.
Find complete records that have or lack a matchEXISTS or NOT EXISTSKeeps the outer table’s rows without join fan-out.
Retrieve related columns from two tablesJOINCombines matching records side by side.
Include matched and unmatched records from both tablesFULL JOINPreserves rows from both sides.
Compare complete rows across two sourcesTwo EXCEPT queries combined with UNION ALLIdentifies rows whose selected values differ or are missing.

Three important differences

1. What does the query return?

Set operations return the columns selected by their queries. For example, using EXCEPT to find customers who have never ordered may return only customer ID 5. If we also need Esha’s name and other details, we would need to retrieve those columns separately or join the result back to customers. With NOT EXISTS, we can select Esha’s details directly from the outer customers table.

2. How are NULL values handled?

Set operations treat two NULL values as matching when comparing rows. A regular join using = does not match NULL with another NULL. For example, INTERSECT may return a shared NULL customer ID, while an INNER JOIN using equality would not match the corresponding rows. When a join should treat two NULL values as matching, use a NULL-safe comparison such as IS NOT DISTINCT FROM, where supported.

3. Which columns determine a match?

Set operations compare every selected column. A join or an EXISTS subquery compares only the conditions we specify. For example, two orders may have the same order_id but different statuses. A join on order_id matches those records, allowing us to compare their statuses. An EXCEPT query selecting both order_id and status treats those rows as different because their selected values do not match.

What about performance?

The duplicate-removing set operations, such as UNION, INTERSECT and EXCEPT, generally require extra work to identify matching rows, often using sorting or hashing. UNION ALL avoids that duplicate-removal step, making it a good choice when duplicates should be kept or have already been handled. However, the best-performing query depends on the database engine, indexes, data distribution and execution plan.

A rule of thumb is to choose the approach that correctly expresses the requirement first. Measure performance when needed.

Combining JOIN with UNION ALL

Set operations and joins can also work together. For example, MySQL does not support FULL JOIN directly. However, we can produce an equivalent result using LEFT JOIN, RIGHT JOIN and UNION ALL.

The requirement is to show every order from both systems, with its current and legacy status side by side.

Orders appearing in only one system must also remain in the result.

Query
SELECT
o.order_id,
o.status AS current_status,
l.status AS legacy_status
FROM orders AS o
LEFT JOIN legacy_orders AS l
ON l.order_id = o.order_id
UNION ALL
SELECT
l.order_id,
o.status,
l.status
FROM orders AS o
RIGHT JOIN legacy_orders AS l
ON l.order_id = o.order_id
WHERE o.order_id IS NULL
ORDER BY order_id;
Result9 rows
order_idcurrent_statuslegacy_status
998NULLpaid
999NULLpaid
1001paidpaid
1002pendingNULL
1003paidNULL
1004cancelledpaid
1005paidNULL
1006paidNULL
1007paidNULL

How does this work?

The first query uses a LEFT JOIN to keep every current order. If a matching legacy order exists, its status is included. Otherwise, legacy_status is NULL. However, this first query cannot return orders that exist only in legacy_orders, such as 998 and 999. The second query uses a RIGHT JOIN to keep legacy orders and checks: WHERE o.order_id IS NULL

This selects only legacy orders that have no matching current order. Finally, UNION ALL combines the two results. Because the second query adds only orders missing from the current system, no order is duplicated between the two results. The result is equivalent to a FULL JOIN on order_id, with the order ID taken from whichever table contains it.

15

Summary

Set operations let us combine and compare the results of separate SQL queries without joining their rows side by side.

  • UNION ALL keeps every row, while UNION removes identical rows based on the selected columns.
  • INTERSECT finds rows present in both results, while EXCEPT finds rows present in the first result but missing from the second.
  • Set operations match columns by position, treat NULL values as matching during row comparison, and can produce unexpected results when records are duplicated or their selected values differ.

Before using a set operation, consider what makes two records the same, whether duplicates should remain, and which result should take priority when records overlap.

Next, in P10, we will learn how to structure larger queries using subqueries and common table expressions (CTEs). These let us break complex SQL problems into smaller, readable steps and reuse intermediate results.

Go deeper

Ask Colearn about this pattern

Not sure whether you want UNION or UNION ALL, or which columns make two rows the same? Ask about the concept, or paste a simplified version of your query.

Instant answers, grounded in the same verified material the diagnostic grades against.

3 of 3 free questions left
Why did my UNION drop a row I needed to count?

UNION removes duplicate rows based on all the columns selected in the query, not on whether the rows represent the same business record.

For example, orders 1002 and 1007 are different orders, but both contain the product Mechanical Keyboard with an amount of 3200.

If the query selects only product and amount, UNION treats these two orders as duplicates and keeps just one row. This causes the total order value to be lower than expected.

How to fix it: Include a unique identifier such as order_id so different orders remain separate. However, if the same order can have different values across systems, UNION alone is not enough. Use UNION ALL with NOT EXISTS to keep one preferred version of each order based on its ID.

Why is my UNION combining the wrong columns?

Set operations match columns by position, not by column name.

For example:

Query
SELECT order_id, customer_id
FROM orders
UNION ALL
SELECT customer_id, order_id
FROM legacy_orders;

The first column of the second query is placed under the first column of the first query, even though they represent different information.

As a result, legacy customer IDs appear under order_id, while legacy order IDs appear under customer_id.

Because both columns contain integers, the query runs successfully without warning.

How to fix it: Ensure every query selects the same number of columns, with compatible data types and the same meaning in each position. Always list columns in the same order across all queries.

Does UNION treat two NULLs as the same row?

Yes. When removing duplicates, UNION treats two NULL values in corresponding columns as matching.

For example, both orders and legacy_orders contain guest orders with NULL customer IDs.

If we combine their customer IDs using UNION, the result contains only one NULL, even though both tables contain it.

This differs from ordinary equality comparisons, where NULL = NULL evaluates to UNKNOWN, not TRUE.

The same comparison rule applies to INTERSECT and EXCEPT.

Important: When multiple columns are selected, two rows are considered duplicates only when every corresponding value matches, treating two NULL values as equivalent.

How do INTERSECT and EXCEPT work?

Both operators compare the rows returned by two queries, but they answer different questions.

INTERSECT returns distinct rows that appear in both results.

For example:

Query
SELECT customer_id FROM orders
INTERSECT
SELECT customer_id FROM legacy_orders;

This returns 1, 3, 4 and NULL, because those values appear in both tables.

EXCEPT returns distinct rows from the first query that do not appear in the second.

For example:

Query
SELECT customer_id FROM customers
EXCEPT
SELECT customer_id FROM orders;

This returns 5, Esha's customer ID, because she has never placed an order.

The important difference is that the order of the queries matters for EXCEPT but not for INTERSECT.

Reversing an EXCEPT changes which rows are retained. Both operations remove duplicates by default, while their ALL variants account for repeated copies.

Why does my EXCEPT list rows that never changed?

EXCEPT compares complete selected rows. It does not distinguish between a missing record and an existing record whose values changed.

For example, comparing (order_id, status) from legacy_orders against orders returns:

Result3 rows
order_idstatus
998paid
999paid
1004paid

Orders 998 and 999 appear because they exist only in the legacy system. Their statuses have not been compared with any current records because no matching current orders exist.

Order 1004 appears because its status changed from paid in the legacy system to cancelled in the current system.

How to fix it: If you want only changed records, first restrict the comparison to order IDs present in both systems. Then use EXCEPT to identify different values.

If you need to display the old and new values side by side, use a JOIN on order_id with a NULL-safe comparison such as IS DISTINCT FROM.

Why does my query with UNION and INTERSECT ignore a filter?

This usually happens because set operators have precedence rules, so SQL may evaluate them in a different order than you intended.

In DuckDB, INTERSECT is evaluated before UNION.

Suppose the requirement is to find Indian customers who have either a paid current order or any legacy order.

Without parentheses, SQL interprets the query as:

Paid current customers UNION (Legacy customers INTERSECT Indian customers)

The country restriction applies only to the legacy customers. Paid current customers outside India are still included.

How to fix it: Use parentheses to make the intended order explicit.

Query
(
SELECT customer_id
FROM orders
WHERE status = 'paid'
UNION
SELECT customer_id
FROM legacy_orders
)
INTERSECT
SELECT customer_id
FROM customers
WHERE country = 'India'
ORDER BY customer_id;

Now SQL first combines customers from both order sources, then keeps only those who are from India.

The correct result contains customer IDs 1 and 4, representing Asha and Dev.

Rule to remember: When combining different set operators, use parentheses to control which operations happen together. This improves readability and avoids differences caused by SQL engine precedence rules.

Don’t paste credentials, personal data, or confidential production records.

Practise this pattern

Now write it yourselfAll 13 Set Operations problems →

Graded problems on this pattern, easy to hard. They run in your browser against real tables – no sign-up needed.

Related patterns

Up next · Pattern 10Subqueries and common table expressions

Next, we structure larger queries using subqueries and common table expressions (CTEs), breaking complex SQL problems into smaller, readable steps and reusing intermediate results.