1. Lighthouse
  2. Lighthouse

Level 1 · Customer retention in SQL

The request

From Catherine Hale, CEO

“The board keeps asking whether our customers come back. I'd like the repeat-purchase rate by the year customers first bought, a 90-day number that's fair to newer customers, and how consumers compare with business accounts.”

Your goal

Step 1 of 3. Return cohort_year (year of the customer's first completed order), customers (customers in the cohort), repeat_customers (those with 2 or more completed orders in total) and repeat_rate_pct (repeat_customers * 100 / customers, rounded to 2). Sort by cohort_year.

  • Read from the customers tablenot done yet
  • Read from the orders tablenot done yet
  • Show exactly cohort_year customers repeat_customers repeat_rate_pctnot done yet

Step 1 · 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.

    Step 2 · your query

    SQL

    Loading the editor…

      ResultPreview of orders · press Run to see yours
      Preview of orders
      order_idcustomer_idorder_datestatuschannel
      10011252023-01-01 20:51:19completedweb
      10021562023-01-02 07:58:46completedweb
      1003222023-01-02 10:25:27completedmarketplace
      1004482023-01-02 11:21:51completedmarketplace

      Mote checks your result

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

      Step 3 · 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
        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
        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

        How an analyst reads this ticket

        “Do customers come back?” sounds like one number. It becomes a useful number only after three decisions:

        1. What counts as an order. Here, completed only. A cancelled order is not a purchase, and a customer whose only order was refunded did not really start a relationship with the store.
        2. Who is in the denominator. Customers who bought at least once. The ~230 sign-ups who never ordered are an acquisition problem, not a retention one. The 18 duplicate customer records have no orders, so they drop out on their own.
        3. How long each customer has had to come back. This is the decision most first attempts miss.

        Step 1: cohorts by first order

        A cohort is a group of customers who started at the same time. The first step is always the same: reduce orders to one row per customer with their first order and their order count. Everything after that is a GROUP BY over this small table. COUNT(*) FILTER (WHERE n_orders >= 2) counts the repeat customers inside each cohort.

        Look at the result before moving on. The 2025 cohort has a much lower repeat rate, and it is tempting to report that retention is collapsing. It isn’t, necessarily: someone who first bought in November 2025 has had a few weeks to order again, while a 2023 customer has had three years. That is right-censoring, and it makes any all-time repeat rate unfair to recent cohorts.

        Step 2: a fair 90-day window

        The standard fix is a fixed window: “repeat within 90 days of the first order”, counted only for customers who have been around for at least 90 days. The data ends on 2025-12-31, so customers whose first order is before 2025-10-01 are eligible.

        To find each customer’s second order, number their completed orders with ROW_NUMBER(), ordered by order_date and then order_id so ties are broken the same way every time. Conditional MAX pivots rn = 1 and rn = 2 into two columns. A one-time buyer has NULL as second_order, and NULL <= anything is not true, so they correctly fail the test without any special handling.

        Compare times with first_order + INTERVAL 90 DAY rather than date_diff('day', ...). date_diff counts calendar-day boundaries, while interval arithmetic measures exactly 90 × 24 hours. Both are defensible; using the interval makes the SQL and pandas versions agree to the customer.

        Step 3: consumer vs business

        Join the per-customer table to customers for the segment. The join is one-to-one, because both sides have one row per customer, so nothing is double counted. Business accounts are few, but they order far more often, which is why the board should see them separately: a blended repeat rate mostly describes consumers.

        SQL and pandas side by side

        Idea SQL pandas
        one row per customer GROUP BY customer_id in a CTE groupby("customer_id").agg(...)
        nth order ROW_NUMBER() OVER (PARTITION BY ...) sort_values + groupby().cumcount()
        plus 90 days + INTERVAL 90 DAY + pd.Timedelta(days=90)
        year of a date year(first_order) .dt.year

        Pitfalls

        • Counting non-completed orders. A cancelled first order would put a customer in the wrong cohort.
        • Joining customers to orders first and then counting. COUNT(*) over that join counts orders, not customers; aggregate to one row per customer first.
        • Comparing cohorts with different exposure. Report the 90-day rate beside the all-time rate, and say why.