The request

From Lauren Hayes, Email Marketing Manager
Your goal
Return customer_id, first_name, last_name and email (from customers) for every customer who has at least one completed order in 2024 and no completed order in 2025. One row per customer.
- Read from the
customerstablenot done yet - Read from the
orderstablenot done yet - Show exactly
customer_idfirst_namelast_nameemailnot done yet
Your query
Loading the editor…
Reference solution
| customer_id | first_name | last_name | city | |
|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
What Lauren gets, and why
Tables
Harbor & Pine store data, ready to query.
customers2,018 rows · 9 columns
customer_idintegerfirst_namevarcharlast_namevarcharemailvarcharcityvarcharstatevarcharsignup_datedatesegmentvarcharreferral_sourcevarchar
| customer_id | first_name | last_name | city | state | signup_date | segment | referral_source | |
|---|---|---|---|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco | CA | 2022-06-02 | consumer | search |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta | GA | 2022-06-02 | consumer | NULL |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles | CA | 2022-06-04 | business | NULL |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle | WA | 2022-06-06 | business | |
| 5 | Kyle | Jimenez | kjimenez27@yahoo.com | Indianapolis | IN | 2022-06-07 | consumer | NULL |
orders12,000 rows · 7 columns
order_idintegercustomer_idintegerorder_datetimestampstatusvarcharchannelvarcharshipping_feedecimalcoupon_codevarchar
| order_id | customer_id | order_date | status | channel | shipping_fee | coupon_code |
|---|---|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web | 0.00 | NULL |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web | 0.00 | NULL |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace | 4.99 | WELCOME10 |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace | 4.99 | WELCOME10 |
| 1005 | 82 | 2023-01-02 11:43:36 | completed | web | 0.00 | WELCOME10 |
products120 rows · 8 columns
product_idintegernamevarcharcategoryvarcharsubcategoryvarcharunit_pricedecimalunit_costdecimallaunched_ondateis_activeboolean
| product_id | name | category | subcategory | unit_price | unit_cost | launched_on | is_active |
|---|---|---|---|---|---|---|---|
| 101 | Vale Velvet Loveseat | Furniture | Sofas | 1659.00 | NULL | 2023-09-11 | true |
| 102 | Alder Oak Coffee Table | Furniture | Tables | 919.00 | 503.50 | 2024-05-16 | true |
| 103 | Orchard Rattan Dining Chair | Furniture | Chairs | 789.00 | 429.87 | 2023-05-27 | true |
| 104 | Upland Oak Bookcase | Furniture | Storage | 429.00 | 214.70 | 2022-12-20 | true |
| 105 | Avery Leather Bed Frame | Furniture | Beds | 449.00 | 232.70 | 2021-08-31 | true |
order_items30,338 rows · 6 columns
order_item_idintegerorder_idintegerproduct_idintegerquantityintegerunit_pricedecimaldiscount_pctdecimal
| order_item_id | order_id | product_id | quantity | unit_price | discount_pct |
|---|---|---|---|---|---|
| 1 | 1001 | 121 | 9 | 31.99 | 0.00 |
| 2 | 1001 | 154 | 3 | 11.99 | 0.00 |
| 3 | 1002 | 154 | 4 | 11.99 | 0.00 |
| 4 | 1002 | 194 | 1 | 149.00 | 0.00 |
| 5 | 1002 | 193 | 2 | 19.99 | 0.00 |
returns1,673 rows · 5 columns
return_idintegerorder_item_idintegerreturned_ondatereasonvarcharrefund_amountdecimal
| return_id | order_item_id | returned_on | reason | refund_amount |
|---|---|---|---|---|
| 1 | 14 | 2023-01-10 | damaged | 83.69 |
| 2 | 42 | 2023-01-10 | wrong_item | 19.99 |
| 3 | 105 | 2023-01-14 | wrong_item | 10.99 |
| 4 | 24 | 2023-01-16 | changed_mind | 26.99 |
| 5 | 126 | 2023-01-16 | late | 20.99 |
employees60 rows · 8 columns
employee_idintegerfirst_namevarcharlast_namevarchardepartmentvarchartitlevarcharmanager_idintegerhire_datedatesalaryinteger
| employee_id | first_name | last_name | department | title | manager_id | hire_date | salary |
|---|---|---|---|---|---|---|---|
| 1 | Catherine | Hale | Executive | Chief Executive Officer | NULL | 2016-03-01 | 285000 |
| 2 | Robert | Kim | Executive | Chief Operating Officer | 1 | 2017-01-09 | 235000 |
| 3 | Megan | Collins | Sales | Head of Sales | 1 | 2018-04-16 | 172000 |
| 4 | Chloe | Gutierrez | Sales | Account Executive | 3 | 2020-01-21 | 72000 |
| 5 | Frank | Bell | Sales | Account Executive | 3 | 2023-05-21 | 73000 |
web_sessions40,000 rows · 7 columns
session_idintegercustomer_idintegerstarted_attimestampdevicevarcharsourcevarcharpages_viewedintegerconvertedboolean
| session_id | customer_id | started_at | device | source | pages_viewed | converted |
|---|---|---|---|---|---|---|
| 1 | NULL | 2023-01-01 23:02:24 | mobile | paid | 1 | false |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid | 1 | false |
| 3 | NULL | 2023-01-01 16:55:46 | desktop | direct | 7 | false |
| 4 | NULL | 2023-01-01 12:43:28 | mobile | organic | 3 | false |
| 5 | NULL | 2023-01-01 13:06:21 | mobile | social | 2 | false |
marketing_spend3,276 rows · 3 columns
spend_datedatechannelvarcharamountdecimal
| spend_date | channel | amount |
|---|---|---|
| 2023-01-01 | paid | 492.99 |
| 2023-01-01 | 37.38 | |
| 2023-01-01 | social | 200.53 |
| 2023-01-02 | paid | 709.13 |
| 2023-01-02 | 64.13 |
The lesson
EXCEPT returns the rows of the first query that do not appear in the second. It is the
“subtract” of set operations, and the order of the two queries matters.
The query
SELECT customer_id FROM orders
WHERE status = 'completed' AND order_date >= '2024-01-01' AND order_date < '2025-01-01'
EXCEPT
SELECT customer_id FROM orders
WHERE status = 'completed' AND order_date >= '2025-01-01' AND order_date < '2026-01-01';
Of the 997 customers who completed an order in 2024, 752 also bought in 2025 (the previous level), and
245 did not. Like UNION and INTERSECT, plain EXCEPT returns distinct rows, so each lapsed
customer appears once. Swap the two queries and you get a different question: 2025 buyers who did not
buy in 2024.
Putting the set operation inside a CTE and joining or filtering afterwards is the clean pattern: the
comparison happens on customer_id alone, and names and emails are added once the list is final.
EXCEPT ALL
EXCEPT ALL (supported in DuckDB and Postgres) works on counts instead of distinct rows: each row on
the right cancels one matching row on the left. On these branches, which are one row per order, it
returns 1,564 rows. That number is neither customers nor orders of lapsed customers, it is “2024
orders left over after cancelling one per 2025 order”. You rarely want it for people lists. It is
useful for reconciling two copies of the same table, where duplicates are real rows.
EXCEPT, NOT EXISTS and NOT IN
The same “in A but not in B” can be written three ways, and they differ on NULLs. Take customers who
never had a web session. web_sessions.customer_id is NULL for anonymous visits:
SELECT customer_id FROM customers
EXCEPT
SELECT customer_id FROM web_sessions; -- 205 rows
NOT EXISTS with a correlated subquery also returns 205. But
WHERE customer_id NOT IN (SELECT customer_id FROM web_sessions) returns 0: once the list
contains a NULL, NOT IN can never be true. EXCEPT treats NULLs as equal to each other and simply
compares rows, so it is immune to that trap. Chapter 11 has a full level on the NOT IN bug.
Common mistakes
- Wrong direction. Read it as “A, except the ones also in B” and check the first query is A.
- Different filters in the two branches. If the 2025 branch forgets
status = 'completed', a customer whose only 2025 order was cancelled stops counting as lapsed. - Extra columns in the comparison.
SELECT customer_id, channelwould call a customer lapsed if they only switched channel. - Calling 245 a churn rate. It is a count. The rate is 245 / 997 = 24.6% of 2024 buyers.
In SQL Server
EXCEPT works the same in SQL Server, but there is no EXCEPT ALL. Oracle calls it MINUS
(EXCEPT from 21c), and MySQL supports EXCEPT from 8.0.31, so older code uses NOT EXISTS.
Lantern lit!
Practised: exceptset operationschurnnot exists
- Solved
- No hints
- First run
