All writing2026.09.22 · CS 411 · Database Systems · 2 min

Joins, NULL, and Set Operations: Keeping Multi-Table Queries Correct

Join cardinality, outer-join filters, NULL-safe anti-joins, and when UNION ALL is the honest operation.

Most join bugs are not syntax errors. The query runs, returns plausible rows, and quietly changes the meaning of the data.

Begin with cardinality

Before writing SQL, state the relationship:

  • one customer has many orders;
  • one order has many items;
  • products and orders are many-to-many through order items.

If ten orders become eighty rows after joining items, that may be correct. If the next step sums orders.total, it is probably wrong because each order total is repeated once per item.

In plain terms: a join combines matching rows; it does not know which numbers are safe to add afterward. Predict the row count before trusting the aggregate.

INNER and LEFT JOIN answer different questions

SELECT c.id, o.id
FROM customer c
JOIN orders o ON o.customer_id = c.id;

This asks for customers that have matching orders. To keep customers with no order, use a left join:

SELECT c.id, o.id
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id;

Condition placement matters:

-- Keeps every customer; only paid orders may match.
LEFT JOIN orders o
  ON o.customer_id = c.id
 AND o.status = 'paid'

-- Removes customers without a paid order; behaves like an inner join.
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'

The first condition controls matching. The second filters the result after unmatched rows have received NULL on the right side.

Anti-joins: prefer NOT EXISTS

This query looks reasonable but is fragile:

SELECT * FROM paper
WHERE venue_id NOT IN (SELECT venue_id FROM blacklist);

If the subquery returns one NULL, every comparison can become UNKNOWN, and the query may return no rows. Express the intent directly:

SELECT p.*
FROM paper p
WHERE NOT EXISTS (
  SELECT 1
  FROM blacklist b
  WHERE b.venue_id = p.venue_id
);

EXISTS and NOT EXISTS test whether a matching row exists; they do not need to compare a value with a list containing unknowns.

UNION is not concatenation

UNION combines results and removes duplicates. UNION ALL simply appends them.

SELECT email FROM current_member
UNION ALL
SELECT email FROM archived_member;

Use UNION ALL when both sources represent distinct events or when duplicates are meaningful. Use UNION only when the result must be a set and the cost of deduplication is justified.

INTERSECT keeps rows in both inputs; EXCEPT keeps rows from the left input that do not appear on the right. All set operations require compatible column counts and types.

A correctness checklist

Question Why it matters
What is the relationship cardinality? Predicts multiplication of rows
Should unmatched left rows survive? Chooses inner versus left join
Does a right-side filter belong in ON or WHERE? Changes outer-join meaning
Can the subquery contain NULL? Makes NOT IN unsafe
Are duplicates meaningful? Chooses UNION ALL versus UNION

Review card

  • Predict join cardinality before aggregating.
  • A filter in WHERE can cancel the outer part of a left join.
  • Use IS NULL, never = NULL.
  • Prefer NOT EXISTS for NULL-safe anti-joins.
  • UNION ALL appends; UNION appends and deduplicates.

大多數 join bug 都不是語法錯誤。查詢會執行,也會回傳看似合理的資料,只是資料的意思已經悄悄改變。

先說清楚 cardinality

寫 SQL 前,先寫出關係:

  • 一位 customer 有多張 order;
  • 一張 order 有多個 item;
  • Product 和 order 透過 order item 形成 many-to-many。

十張訂單 join items 後變成八十 rows,可能完全正確。但下一步若加總 orders.total,通常就錯了,因為每張訂單總額會隨 item 數量重複。

白話來說: Join 只負責組合符合條件的 rows,不知道哪些數字可以安全加總。相信 aggregate 前,先預測 join 後應該有幾列。

INNER 與 LEFT JOIN 回答不同問題

SELECT c.id, o.id
FROM customer c
JOIN orders o ON o.customer_id = c.id;

這是在找「有訂單的 customer」。若要保留沒有訂單的人,必須使用 left join:

SELECT c.id, o.id
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id;

條件放在哪裡也會改變答案:

-- 保留所有 customer;只有 paid order 能配對。
LEFT JOIN orders o
  ON o.customer_id = c.id
 AND o.status = 'paid'

-- 移除沒有 paid order 的 customer,效果接近 inner join。
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'

第一個條件決定如何配對;第二個是在配對完成後過濾結果。沒有配對的右側欄位已經是 NULL,自然無法通過 WHERE o.status = 'paid'

Anti-join 優先使用 NOT EXISTS

下面的查詢看似合理,卻很脆弱:

SELECT * FROM paper
WHERE venue_id NOT IN (SELECT venue_id FROM blacklist);

只要子查詢回傳一個 NULL,比較結果就可能變成 UNKNOWN,最後一列都沒有。直接表達「不存在配對資料」比較安全:

SELECT p.*
FROM paper p
WHERE NOT EXISTS (
  SELECT 1
  FROM blacklist b
  WHERE b.venue_id = p.venue_id
);

EXISTSNOT EXISTS 檢查的是有沒有配對 row,不必把值拿去和可能含有未知值的清單逐一比較。

UNION 不是單純串接

UNION 合併結果後還會去重;UNION ALL 只把兩批資料接起來。

SELECT email FROM current_member
UNION ALL
SELECT email FROM archived_member;

兩邊若代表不同事件,或重複本身有意義,就應使用 UNION ALL。只有結果必須符合 set semantics 時,才值得支付 UNION 的排序或 hash 去重成本。

INTERSECT 保留兩邊都有的 row;EXCEPT 保留左邊有、右邊沒有的 row。所有 set operation 都要求欄位數量與型別相容。

正確性檢查表

問題 為什麼重要
Relationship cardinality 是什麼? 預測 join 後會放大幾倍
沒配對的左側 row 要不要保留? 決定 inner 或 left join
右表條件該放 ON 還是 WHERE 會改變 outer join 語意
子查詢可能含 NULL 嗎? 決定 NOT IN 是否安全
重複資料有意義嗎? 決定 UNION ALLUNION

複習卡

  • Aggregate 前先預測 join cardinality。
  • WHERE 的右表條件可能讓 left join 失去 outer 語意。
  • 使用 IS NULL,不要寫 = NULL
  • NULL-safe anti-join 優先用 NOT EXISTS
  • UNION ALL 是串接;UNION 還會去重。