In Q1, we applied DISTINCT to one column, as in SELECT DISTINCT user_id. When you list multiple columns in SELECT, DISTINCT treats all of them together as one key when checking for duplicates.
SELECT DISTINCT user_id -- check only user_id → Q1 SELECT DISTINCT user_id, category -- check the (user_id, category) pair SELECT DISTINCT user_id, category, dt -- check the 3-column combination (even looser)
purchase_logs records the same user purchasing the same category multiple times. Return the distinct combinations of categories that each user has purchased.
| order_id | user_id | category | amount |
|---|---|---|---|
| 1 | 101 | Books | 1500 |
| 2 | 101 | Electronics | 3000 |
| 3 | 102 | Books | 800 |
| 4 | 101 | Books | 2000 |
| 5 | 102 | Electronics | 5000 |
| 6 | 103 | Books | 1200 |
| 7 | 103 | Books | 900 |
Note: (101, Books) and (103, Books) each appear twice. Deduplicate by the (user_id, category) combination, not by user_id alone.
| user_id | category |
|---|---|
| 101 | Books |
| 101 | Electronics |
| 102 | Books |
| 102 | Electronics |
| 103 | Books |
SELECT DISTINCT -- remove duplicates by the 2-column combination user_id, category FROM purchase_logs ORDER BY user_id, category; /* Logical evaluation order: 1. FROM purchase_logs → read the rows 2. SELECT user_id, category → project 2 columns 3. DISTINCT → remove duplicate combinations 4. ORDER BY user_id, category → sort and output */
LEGEND
① Source data
FROM purchase_logsRead the 7 rows of purchase_logs. Looking at both user_id and category, (101, Books) is duplicated twice (order_id=1,4), as is (103, Books) (order_id=6,7).| order_id | user_id | category | amount |
|---|---|---|---|
| 1 | 101 | Books | 1500 |
| 2 | 101 | Electronics | 3000 |
| 3 | 102 | Books | 800 |
| 4 | 101 | Books | 2000 |
| 5 | 102 | Electronics | 5000 |
| 6 | 103 | Books | 1200 |
| 7 | 103 | Books | 900 |
DISTINCT (2 columns): 7 rows → 5 combinations
DISTINCT user_id, category, pairs such as (101, Books) / (101, Electronics) / (102, Books) … remain separate unless the pair matches. This is why adding columns tends to loosen the uniqueness criterion and increase the number of result rows.SELECT DISTINCT user_id returns only 3 combinations (101/102/103), while SELECT DISTINCT user_id, category returns 5 combinations. A different category remains a separate row even when user_id is the same. Decide what should count as “unique” first, then SELECT only those columns to get accurate results.SELECT DISTINCT order_id, user_id, category includes a primary-key-like unique column (order_id), so every row becomes a different combination and DISTINCT has no effect. To remove duplicates, select only the key columns you want to compare. If you need other information, use a subquery or JOIN.SELECT DISTINCT(user_id), category treats the parentheses as ordinary grouping, so DISTINCT ultimately applies to the (user_id, category) combination. This unintentionally makes the check two-column and can produce more rows than expected. DISTINCT is a keyword, so avoid putting parentheses immediately after it.Q5 introduced UNION, a set operation that “adds” two lists. EXCEPT (called MINUS in Oracle) is the operation that “subtracts”: it removes rows in the right SELECT result from the left SELECT result.
SELECT email FROM A EXCEPT -- keep rows present in A but not in B (set difference A − B) SELECT email FROM B;
A EXCEPT B and B EXCEPT A produce different results. Unlike UNION, EXCEPT is non-commutative. Always keep track of which side you subtract from. EXCEPT also removes duplicates internally, so two identical values on the A side become one.all_subscribers stores every newsletter subscriber, while unsubscribed stores the opt-out list. Return the email addresses of the current active subscribers who have not unsubscribed.
| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
| sato@ex.com |
| yamada@ex.com |
Note: subtract the 2 rows in unsubscribed (sato, yamada) from the 4 rows in all_subscribers. Order the result with ORDER BY email.
| suzuki@ex.com |
| tanaka@ex.com |
SELECT email FROM all_subscribers EXCEPT -- subtract the right side from the left (set difference A − B) SELECT email FROM unsubscribed ORDER BY email; /* Logical evaluation order: 1. SELECT email FROM all_subscribers → get the upper list of 4 rows 2. SELECT email FROM unsubscribed → get the lower list of 2 rows 3. EXCEPT → remove rows from the upper list that appear below (set difference: A − B) 4. ORDER BY email → sort email in ascending order */
LEGEND
① Upper list — all_subscribers
SELECT email FROM all_subscribersThis is the list of all newsletter subscribers (4 rows). EXCEPT removes rows that also appear in the opt-out list.| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
all_subs(4) EXCEPT unsubscribed(2) = 2 rows
EXCEPT ALL (PostgreSQL-compatible).WHERE email NOT IN (SELECT email FROM unsubscribed), but EXCEPT makes the intent clearer and is easier to read. However, NOT IN requires care with NULL (a NULL in the subquery can easily cause a bug that excludes every row). EXCEPT handles rows containing NULL correctly, making it safer for difference detection.all_subscribers EXCEPT unsubscribed (get active subscribers) and unsubscribed EXCEPT all_subscribers (on the opt-out list but absent from the subscriber roster = detect inconsistent data) have completely different meanings. Clarify which side is the base and which side is subtracted before writing the query.In Q3, GROUP BY + HAVING COUNT(*) > 1 returned a list of “duplicate keys.” However, it only tells you which values are duplicated; it cannot return the specific rows (or other column information such as order_id or amount). Combining a subquery with IN lets you retrieve every row whose key is duplicated.
WHERE (user_id, product_id) IN ( SELECT user_id, product_id FROM orders GROUP BY user_id, product_id HAVING COUNT(*) > 1 -- list only duplicated combinations )
The orders table contains duplicate (user_id, product_id) combinations. Return every order row whose combination is duplicated, including both rows in each duplicate pair.
| order_id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 101 | P001 | 3000 |
| 2 | 102 | P002 | 1500 |
| 3 | 101 | P001 | 3000 |
| 4 | 103 | P003 | 2000 |
| 5 | 102 | P002 | 1500 |
| 6 | 103 | P001 | 5000 |
Note: the duplicated combinations are (101, P001) and (102, P002). Each has two rows, so the result contains 4 rows in total.
| order_id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 101 | P001 | 3000 |
| 3 | 101 | P001 | 3000 |
| 2 | 102 | P002 | 1500 |
| 5 | 102 | P002 | 1500 |
SELECT order_id, user_id, product_id, amount FROM orders WHERE (user_id, product_id) IN ( SELECT user_id, product_id FROM orders GROUP BY user_id, product_id HAVING COUNT(*) > 1 -- list only duplicated combinations ) ORDER BY user_id, product_id, order_id; /* Logical evaluation order: 1. Subquery GROUP BY + HAVING → extract duplicate keys 2. FROM orders → read all rows in the outer query 3. WHERE (...) IN → keep only rows with duplicate keys 4. SELECT → project the columns 5. ORDER BY user_id, product_id, order_id → sort and output */
LEGEND
① Source data
FROM ordersRead the 6 rows of orders. order_id=1 and 3 are duplicates of (101, P001), and order_id=2 and 5 are duplicates of (102, P002). The goal is to retrieve all of these rows.| order_id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 101 | P001 | 3000 |
| 2 | 102 | P002 | 1500 |
| 3 | 101 | P001 | 3000 |
| 4 | 103 | P003 | 2000 |
| 5 | 102 | P002 | 1500 |
| 6 | 103 | P001 | 5000 |
GROUP BY + HAVING → key list → WHERE IN → retrieve all rows
GROUP BY + HAVING COUNT(*) > 1 returns only the keys—“which emails are duplicated.” In practice, you often also need other columns (order date, amount, and so on), so the standard next step is to pull every row with those duplicate keys using a subquery + IN.WHERE (user_id, product_id) IN (...) Comparing multiple columns together with IN is called row-tuple comparison. It is available in PostgreSQL / MySQL 5.5+. With this pattern, you do not need to write one condition per row; you can compare the subquery’s multi-row result directly.INNER JOIN (subquery) dup ON orders.user_id = dup.user_id AND orders.product_id = dup.product_id. For large datasets, JOIN may be easier for the optimizer to optimize, but IN is often more readable.WHERE key NOT IN (..., NULL, ...) becomes false for every row. IN (the positive form) is safe here, but when using NOT IN, use WHERE key NOT IN (SELECT key FROM ... WHERE key IS NOT NULL) and make a habit of adding IS NOT NULL.INTERSECT (set intersection) returns only the common part of two SELECT results. Together with Q5’s UNION (set union) and Q7’s EXCEPT (set difference), it forms the three core set operations.
SELECT email FROM A INTERSECT -- keep rows present in both A and B (set intersection A ∩ B) SELECT email FROM B; -- Set-operation summary UNION -- A ∪ B: add (remove duplicates) INTERSECT -- A ∩ B: take the common part EXCEPT -- A − B: subtract
INTERSECT ALL to preserve duplicates (PostgreSQL-compatible).Return the email addresses of users who signed up for both the spring and summer campaigns.
| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
| sato@ex.com |
| kimura@ex.com |
| tanaka@ex.com |
| suzuki@ex.com |
Note: sato, suzuki, and tanaka are the 3 people in both campaigns. Exclude yamada, who is only in spring, and kimura, who is only in summer.
| sato@ex.com |
| suzuki@ex.com |
| tanaka@ex.com |
SELECT email FROM campaign_spring INTERSECT -- keep only rows present in both (set intersection A ∩ B) SELECT email FROM campaign_summer ORDER BY email; /* Logical evaluation order: 1. SELECT email FROM campaign_spring → get the upper list of 4 rows 2. SELECT email FROM campaign_summer → get the lower list of 4 rows 3. INTERSECT → keep only rows present in both (A ∩ B) 4. ORDER BY email → sort email in ascending order */
LEGEND
① Upper list — campaign_spring
SELECT email FROM campaign_springThis is the spring campaign signup list (4 rows). Because yamada appears only in spring, INTERSECT excludes it.| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
spring(4) ∩ summer(4) = 3 common rows
UNION (set union: A ∪ B), INTERSECT (set intersection: A ∩ B), and EXCEPT (set difference: A − B). All remove duplicates internally; adding ALL (UNION ALL / INTERSECT ALL / EXCEPT ALL) preserves duplicates. Write ORDER BY only once, at the end of the entire statement.SELECT s.email FROM campaign_spring s INNER JOIN campaign_summer u ON s.email = u.email. JOIN is more flexible because it can reference other columns, but INTERSECT communicates the “present in both or not” purpose clearly and is readable. INTERSECT removes duplicates automatically, whereas JOIN can multiply duplicate rows.spring INTERSECT summer and summer INTERSECT spring return the same result. The column count and types must still match, and ORDER BY still belongs once at the end.IN + subquery (the Q8 pattern) or INNER JOIN as alternatives. Check the production database version before using it. PostgreSQL supports both INTERSECT and EXCEPT as standard set operations.In Q4, we used ROW_NUMBER() (a window function) to choose one representative row per email. With MIN(id) + GROUP BY, you can achieve the same deduplication without a window function. It also works on older databases and has a simple structure.
-- Q4 approach (window function) ROW_NUMBER() OVER (PARTITION BY email ORDER BY id ASC) = 1 -- Q10 approach (aggregation + subquery) WHERE id IN (SELECT MIN(id) FROM t GROUP BY email) -- switch to MAX(id) when you want to keep the latest row
The contacts table contains multiple registrations with the same email address. Without using ROW_NUMBER(), return one row per email, keeping only the first row (the smallest contact_id).
| contact_id | name | source | |
|---|---|---|---|
| 1 | tanaka@ex.com | Ichiro Tanaka | web |
| 2 | sato@ex.com | Hanako Sato | web |
| 3 | tanaka@ex.com | Taro Tanaka | app |
| 4 | suzuki@ex.com | Jiro Suzuki | web |
| 5 | sato@ex.com | Miko Sato | sns |
| 6 | yamada@ex.com | Ken Yamada | web |
Note: tanaka@ex.com (id=1,3) and sato@ex.com (id=2,5) are duplicated. Use MIN(contact_id) to keep the row with the smallest ID for each email.
| contact_id | name | source | |
|---|---|---|---|
| 1 | tanaka@ex.com | Ichiro Tanaka | web |
| 2 | sato@ex.com | Hanako Sato | web |
| 4 | suzuki@ex.com | Jiro Suzuki | web |
| 6 | yamada@ex.com | Ken Yamada | web |
SELECT c.contact_id, c.email, c.name, c.source FROM contacts c WHERE c.contact_id IN ( SELECT MIN(contact_id) -- choose the smallest ID for each email FROM contacts GROUP BY email ) ORDER BY c.contact_id; /* Logical evaluation order: 1. Subquery GROUP BY email → extract the smallest ID for each email 2. FROM contacts c → read all rows in the outer query 3. WHERE contact_id IN (...) → keep rows with the smallest IDs 4. SELECT → project the columns 5. ORDER BY c.contact_id → sort and output */
LEGEND
① Source data
FROM contactsRead the 6 rows of contacts. Using email as the duplicate key, tanaka@ex.com (id=1,3) and sato@ex.com (id=2,5) are duplicated. Choose the row with the smallest ID for each email.| contact_id | name | source | |
|---|---|---|---|
| 1 | tanaka@ex.com | Ichiro Tanaka | web |
| 2 | sato@ex.com | Hanako Sato | web |
| 3 | tanaka@ex.com | Taro Tanaka | app |
| 4 | suzuki@ex.com | Jiro Suzuki | web |
| 5 | sato@ex.com | Miko Sato | sns |
| 6 | yamada@ex.com | Ken Yamada | web |
GROUP BY email → MIN(id) → WHERE IN → one representative row
ORDER BY created_at DESC. MIN/MAX only supports the fixed choice of keeping the row with the smallest or largest ID. “Keep the oldest registration (MIN)” and “keep the newest registration (MAX)” are simple cases where Q10 is sufficient. For compound conditions such as “prefer the newer date, then the larger ID on the same date,” use Q4’s ROW_NUMBER.{1, 2, 4, 6}, and the outer query’s WHERE contact_id IN (1, 2, 4, 6) matches that list. Q8 used IN with a multi-column tuple, whereas Q10 uses the simpler one-column scalar form. This pattern—build a condition list in a subquery and filter with IN—applies broadly.DELETE FROM contacts WHERE contact_id NOT IN (SELECT MIN(contact_id) FROM contacts GROUP BY email). NOT IN deletes the inverse of the subquery result (everything except the representatives). For safety, inspect the deletion targets with SELECT before running DELETE.SELECT email, MIN(contact_id), name FROM contacts GROUP BY email, PostgreSQL and most MySQL modes raise an error because name is neither aggregated nor included in GROUP BY. To get the other columns (name, source) from the row selected by MIN(id), as in Q10, use an outer query with JOIN/IN on contacts to retrieve the complete row in two stages.