1. Lighthouse

Level 2 · Keep one record per person

The request

From Tyler Morgan, Junior Data Engineer

“We found the duplicate customer emails last month. Now I need a merge map for the CRM: for every duplicate record, which customer_id do we keep? Keep whoever signed up first.”

Your goal

Treat customer rows as the same person when LOWER(TRIM(email)) matches. Number each person's rows with ROW_NUMBER, earliest signup_date first (ties broken by customer_id), and keep row 1. Return one row per dropped record with: email (trimmed and lowercased), kept_customer_id and dropped_customer_id. Sort by dropped_customer_id ascending.

  • Read from the customers tablenot done yet
  • Show exactly email kept_customer_id dropped_customer_idnot done yet
  • 18 rows, sorted as askednot 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
    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
    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
    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

    Chapter 7 found duplicate customers with GROUP BY ... HAVING COUNT(*) > 1. That tells you which emails repeat, but a group-by collapses the rows, so you cannot easily keep one whole record and drop the rest. ROW_NUMBER can.

    The dedupe pattern

    Number the rows inside each group of duplicates, in the order that decides the winner, and keep number 1:

    WITH ranked AS (
      SELECT
        *,
        ROW_NUMBER() OVER (
          PARTITION BY LOWER(TRIM(email))      -- what makes two rows the same person
          ORDER BY signup_date, customer_id    -- who wins: earliest, then lowest id
        ) AS rn
      FROM customers
    )
    SELECT * FROM ranked WHERE rn = 1;

    Three decisions are hidden in that window, and each one should be deliberate:

    • The key in PARTITION BY. Here a cleaned email. On this data the raw email column has 2,012 distinct values, the cleaned one 2,000.
    • The winner in ORDER BY. Earliest signup is a common rule. Others are “the record with the most orders” or “the most recently updated”.
    • A tie-breaker. customer_id makes the result the same every run.

    The query above returns 2,000 rows from 2,018: one per person.

    Keep and map

    Deleting duplicates is rarely enough. Orders, tickets and emails may point at the dropped ids, so the CRM team needs a map from each dropped id to the id that survives. Join the ranked rows to themselves, duplicates to the winner with the same key. For example, customer 2014 (BJONES76@GMAIL.COM) maps to customer 1698 (bjones76@gmail.com). In this dataset none of the 18 duplicates has an order yet, so the merge loses nothing.

    Common mistakes

    • Partitioning by the raw column. The starter does this and finds only 6 duplicates, the exact copies. The other 12 differ by case or by a leading space.
    • Using RANK instead of ROW_NUMBER. Two records with the same signup date would both get rank 1 and both survive.
    • SELECT DISTINCT. It only removes rows that are identical in every column. Duplicate people almost always differ somewhere (the id, at least).
    • Filtering rn in the same SELECT. A window value cannot go in that query’s WHERE. Compute it in a CTE and filter outside. DuckDB, Snowflake and BigQuery also accept QUALIFY rn = 1.

    In SQL Server

    The pattern is identical, and it is a favorite in T-SQL courses and interviews. SQL Server has no QUALIFY, so the CTE is required. A CTE can even delete the duplicates in place:

    WITH ranked AS (
      SELECT *, ROW_NUMBER() OVER (PARTITION BY LOWER(TRIM(email)) ORDER BY signup_date, customer_id) AS rn
      FROM customers
    )
    DELETE FROM ranked WHERE rn > 1;

    Pick what may run on this site. You can change this any time from "Cookie settings" at the bottom of every page.