1. Lighthouse

Level 2 · Customers who returned something

The request

From Rachel Adams, Customer Support Lead

“We want to survey customers who have returned at least one item. Can you list them, once each, with their names? I don't need the return details.”

Your goal

Return customer_id, first_name and last_name for every customer who has at least one row in returns (returns link to order_items, which link to orders, which link to customers). Each customer must appear only once.

  • Read from the customers tablenot done yet
  • Read from the orders tablenot done yet
  • Show exactly customer_id first_name last_namenot done yet

Your query

SQL

Loading the editor…

    ResultPreview of customers · press Run to see yours
    Preview of customers
    customer_idfirst_namelast_nameemailcity
    1OwenHudsonowen.hudson@icloud.comSan Francisco
    2DouglasMendozadmendoza69@gmail.comAtlanta
    3HenryBrookshbrooks@lakesidecafe.comLos Angeles
    4BrianGrahambgraham@bluecedarhomes.comSeattle

    Mote checks your result

    Press Run. Each row that matches the goal becomes a firefly in your lantern.

    Tables

    Harbor & Pine store data, ready to query.

    customers2,018 rows · 9 columns
    • customer_idinteger
    • first_namevarchar
    • last_namevarchar
    • emailvarchar
    • cityvarchar
    • statevarchar
    • signup_datedate
    • segmentvarchar
    • referral_sourcevarchar
    First 5 rows of customers
    customer_idfirst_namelast_nameemailcitystatesignup_datesegmentreferral_source
    1OwenHudsonowen.hudson@icloud.comSan FranciscoCA2022-06-02consumersearch
    2DouglasMendozadmendoza69@gmail.comAtlantaGA2022-06-02consumerNULL
    3HenryBrookshbrooks@lakesidecafe.comLos AngelesCA2022-06-04businessNULL
    4BrianGrahambgraham@bluecedarhomes.comSeattleWA2022-06-06businesspinterest
    5KyleJimenezkjimenez27@yahoo.comIndianapolisIN2022-06-07consumerNULL
    orders12,000 rows · 7 columns
    • order_idinteger
    • customer_idinteger
    • order_datetimestamp
    • statusvarchar
    • channelvarchar
    • shipping_feedecimal
    • coupon_codevarchar
    First 5 rows of orders
    order_idcustomer_idorder_datestatuschannelshipping_feecoupon_code
    10011252023-01-01 20:51:19completedweb0.00NULL
    10021562023-01-02 07:58:46completedweb0.00NULL
    1003222023-01-02 10:25:27completedmarketplace4.99WELCOME10
    1004482023-01-02 11:21:51completedmarketplace4.99WELCOME10
    1005822023-01-02 11:43:36completedweb0.00WELCOME10
    order_items30,338 rows · 6 columns
    • order_item_idinteger
    • order_idinteger
    • product_idinteger
    • quantityinteger
    • unit_pricedecimal
    • discount_pctdecimal
    First 5 rows of order_items
    order_item_idorder_idproduct_idquantityunit_pricediscount_pct
    11001121931.990.00
    21001154311.990.00
    31002154411.990.00
    410021941149.000.00
    51002193219.990.00
    returns1,673 rows · 5 columns
    • return_idinteger
    • order_item_idinteger
    • returned_ondate
    • reasonvarchar
    • refund_amountdecimal
    First 5 rows of returns
    return_idorder_item_idreturned_onreasonrefund_amount
    1142023-01-10damaged83.69
    2422023-01-10wrong_item19.99
    31052023-01-14wrong_item10.99
    4242023-01-16changed_mind26.99
    51262023-01-16late20.99
    products120 rows · 8 columns
    • product_idinteger
    • namevarchar
    • categoryvarchar
    • subcategoryvarchar
    • unit_pricedecimal
    • unit_costdecimal
    • launched_ondate
    • is_activeboolean
    First 5 rows of products
    product_idnamecategorysubcategoryunit_priceunit_costlaunched_onis_active
    101Vale Velvet LoveseatFurnitureSofas1659.00NULL2023-09-11true
    102Alder Oak Coffee TableFurnitureTables919.00503.502024-05-16true
    103Orchard Rattan Dining ChairFurnitureChairs789.00429.872023-05-27true
    104Upland Oak BookcaseFurnitureStorage429.00214.702022-12-20true
    105Avery Leather Bed FrameFurnitureBeds449.00232.702021-08-31true
    employees60 rows · 8 columns
    • employee_idinteger
    • first_namevarchar
    • last_namevarchar
    • departmentvarchar
    • titlevarchar
    • manager_idinteger
    • hire_datedate
    • salaryinteger
    First 5 rows of employees
    employee_idfirst_namelast_namedepartmenttitlemanager_idhire_datesalary
    1CatherineHaleExecutiveChief Executive OfficerNULL2016-03-01285000
    2RobertKimExecutiveChief Operating Officer12017-01-09235000
    3MeganCollinsSalesHead of Sales12018-04-16172000
    4ChloeGutierrezSalesAccount Executive32020-01-2172000
    5FrankBellSalesAccount Executive32023-05-2173000
    web_sessions40,000 rows · 7 columns
    • session_idinteger
    • customer_idinteger
    • started_attimestamp
    • devicevarchar
    • sourcevarchar
    • pages_viewedinteger
    • convertedboolean
    First 5 rows of web_sessions
    session_idcustomer_idstarted_atdevicesourcepages_viewedconverted
    1NULL2023-01-01 23:02:24mobilepaid1false
    2182023-01-01 15:07:49desktoppaid1false
    3NULL2023-01-01 16:55:46desktopdirect7false
    4NULL2023-01-01 12:43:28mobileorganic3false
    5NULL2023-01-01 13:06:21mobilesocial2false
    marketing_spend3,276 rows · 3 columns
    • spend_datedate
    • channelvarchar
    • amountdecimal
    First 5 rows of marketing_spend
    spend_datechannelamount
    2023-01-01paid492.99
    2023-01-01email37.38
    2023-01-01social200.53
    2023-01-02paid709.13
    2023-01-02email64.13

    The lesson

    “Customers who did X” is a semi-join: keep rows from one table when a related row exists in another, without bringing that table’s columns along. There are three ways to write it, and they do not behave the same.

    JOIN creates duplicates

    SELECT c.customer_id, c.first_name
    FROM customers AS c
    JOIN orders AS o ON o.customer_id = c.customer_id
    JOIN order_items AS i ON i.order_id = o.order_id
    JOIN returns AS r ON r.order_item_id = i.order_item_id;

    A customer with three returns appears three times. You could add DISTINCT, but that is the “hide the fan-out” habit from chapter 1. It works here and quietly breaks the moment someone adds a SUM.

    EXISTS

    SELECT c.customer_id, c.first_name, c.last_name
    FROM customers AS c
    WHERE EXISTS (
      SELECT 1
      FROM orders AS o
      JOIN order_items AS i ON i.order_id = o.order_id
      JOIN returns AS r ON r.order_item_id = i.order_item_id
      WHERE o.customer_id = c.customer_id
    );

    EXISTS is true if the subquery returns at least one row. Each customer is tested once and kept once, however many returns they have. The SELECT 1 is a convention; EXISTS ignores what the subquery selects. The WHERE o.customer_id = c.customer_id line makes it correlated: it ties the inner query to the current outer customer. Forget it and EXISTS is true for everyone as soon as any return exists.

    IN

    WHERE c.customer_id IN (
      SELECT o.customer_id
      FROM orders AS o
      JOIN order_items AS i ON i.order_id = o.order_id
      JOIN returns AS r ON r.order_item_id = i.order_item_id
    )

    Same result, and many people find it easier to read. Duplicates in the list do not matter.

    NOT IN and the NULL trap

    The negative forms are where they differ. x NOT IN (1, 2, NULL) means x <> 1 AND x <> 2 AND x <> NULL. The last comparison is unknown, so the whole condition is never true and no rows come back. If the subquery column can contain even one NULL, NOT IN returns nothing:

    -- returns zero rows: web_sessions.customer_id is NULL for anonymous visitors
    SELECT customer_id FROM customers
    WHERE customer_id NOT IN (SELECT customer_id FROM web_sessions);

    NOT EXISTS has no such problem, because it only asks whether a matching row exists:

    WHERE NOT EXISTS (
      SELECT 1 FROM web_sessions AS s WHERE s.customer_id = c.customer_id
    )

    Rule of thumb: IN and EXISTS are interchangeable for “has a match”. For “has no match”, prefer NOT EXISTS or the LEFT JOIN anti-join from chapter 5.

    Performance

    Old advice says EXISTS is faster than IN, or the reverse. Modern optimizers in Postgres, DuckDB, BigQuery and MySQL 8 turn both into the same semi-join plan most of the time. Choose for correctness and readability.

    Interview note

    “Find customers who never visited the site” with a NOT IN that returns zero rows is a favorite trick question. Explaining the NULL behavior, and fixing it with NOT EXISTS, is the answer that stands out.