1. Lighthouse
  2. Lighthouse

Level 2 · Customer retention in pandas

The request

From Catherine Hale, CEO

“Priya says the retention numbers should live in a notebook we can rerun each quarter. Please build the cohort, 90-day and segment tables in pandas, matching the SQL version of this case.”

Your goal

Step 1 of 3. Set result to a DataFrame with columns cohort_year (int, year of the first completed order), customers, repeat_customers (2 or more completed orders) and repeat_rate_pct (repeat_customers * 100 / customers, rounded to 2), sorted by cohort_year, with a default index.

  • Use the orders DataFramenot done yet
  • Show exactly cohort_year customers repeat_customers repeat_rate_pctnot done yet
  • 3 rows, sorted as askednot done yet

Step 1 · your code

Python

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 2 · your code

    Python

    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 code

      Python

      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.

        DataFrames

        Loaded for you as pandas DataFrames, plus pd and np.

        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.00NaN
        10021562023-01-02 07:58:46completedweb0.00NaN
        1003222023-01-02 10:25:27completedmarketplace4.99WELCOME10
        1004482023-01-02 11:21:51completedmarketplace4.99WELCOME10
        1005822023-01-02 11:43:36completedweb0.00WELCOME10
        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-02consumerNaN
        3HenryBrookshbrooks@lakesidecafe.comLos AngelesCA2022-06-04businessNaN
        4BrianGrahambgraham@bluecedarhomes.comSeattleWA2022-06-06businesspinterest
        5KyleJimenezkjimenez27@yahoo.comIndianapolisIN2022-06-07consumerNaN
        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.00NaN2023-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 OfficerNaN2016-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
        1NaN2023-01-01 23:02:24mobilepaid1false
        2182023-01-01 15:07:49desktoppaid1false
        3NaN2023-01-01 16:55:46desktopdirect7false
        4NaN2023-01-01 12:43:28mobileorganic3false
        5NaN2023-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

        Same question, same definitions

        Catherine’s retention review in pandas, with the same definitions as the SQL level of this case: completed orders only, cohorts by the year of the first completed order, “repeat” means two or more completed orders, and the 90-day rate only counts customers who had 90 days to come back.

        Step 1: one row per customer

        One row per customer is a groupby without as_index=False, so customer_id stays as the index:

        completed = orders[orders["status"] == "completed"]
        per_customer = completed.groupby("customer_id").agg(
            first_order=("order_date", "min"), n_orders=("order_id", "count")
        )

        From here every question is a second groupby over this small table. A boolean is_repeat column makes the counting readable: "size" counts every customer in the cohort, "sum" counts the True values. .dt.year gives an integer year, the same as SQL’s year().

        Look at the 2025 cohort before moving on: its all-time repeat rate is much lower. That is right-censoring, not necessarily a collapse. Someone who first bought in November 2025 has had a few weeks to come back, a 2023 customer three years, so any all-time rate is unfair to recent cohorts. Step 2 fixes that with a fixed 90-day window.

        Step 2: the second order with cumcount

        To find each customer’s second order, number their orders. groupby(...).cumcount() numbers the rows inside each group 0, 1, 2, … in the order they currently sit, so sort first, then number:

        completed = orders[orders["status"] == "completed"].sort_values(["customer_id", "order_date", "order_id"])
        completed["n"] = completed.groupby("customer_id").cumcount()   # 0, 1, 2, ...

        cumcount starts at 0, so the first order is n == 0 and the second is n == 1. Sorting by order_id as well as the date breaks ties exactly as the SQL version does.

        pivot(index="customer_id", columns="n", values="order_date") turns the two rows per customer into two columns. It is pivot_table without an aggregation, for when each index/column pair has one value. The new columns are named after the values of n, 0 and 1, so .rename(columns={0: "first_order", 1: "second_order"}) gives them readable names (old name on the left, new name on the right). One-time buyers get NaT (missing timestamp) as their second order. Comparisons with NaT return False, so they fail the 90-day test with no extra code.

        pd.Timedelta(days=90) is a length of time, exactly 90 × 24 hours, and adding it to a datetime moves it forward: pd.Timestamp("2025-01-10 14:00") + pd.Timedelta(days=90) is 2025-04-10 14:00. It is the pandas spelling of SQL’s INTERVAL 90 DAY. Using (second - first).dt.days <= 90 would also be reasonable but can differ by a day from the SQL interval near midnight; keeping the same arithmetic in both is what makes the numbers match.

        Step 3: merge for the segment

        customers has one row per customer and so does per_customer, so validate="one_to_one" should pass, and it does. If the duplicate customer records (ids 2001–2018) had orders, this is where they would show up; they don’t, but the check costs nothing.

        ("n_orders", "mean") gives orders per customer, the same as SQL’s AVG(n_orders). Select the columns in the order the task lists before returning.

        SQL and pandas side by side

        Idea SQL pandas
        customers in a cohort COUNT(*) ("is_repeat", "size")
        repeaters COUNT(*) FILTER (WHERE n_orders >= 2) ("is_repeat", "sum")
        nth order ROW_NUMBER() sort_values + cumcount()
        rows to columns MAX(CASE WHEN rn = 2 ...) pivot(columns="n")
        missing second order NULL NaT

        Pitfalls

        • Forgetting to sort before cumcount. It numbers rows in their current order, which is not guaranteed to be by date after a filter or merge.
        • count vs size. "count" skips missing values, "size" does not. On a column with no missing values they agree; know which one you mean.
        • Eligibility on the wrong date. Filter on the first order, not on signup date: customers sign up before they buy, sometimes months before.