The request

From Catherine Hale, CEO
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
customerstablenot done yet - Read from the
orderstablenot done yet - Show exactly
cohort_yearcustomersrepeat_customersrepeat_rate_pctnot done yet
Step 2 of 3. Only customers whose first completed order is before 2025-10-01 are eligible (they all had 90 days to come back). Return cohort_year, eligible_customers, repeat_90d (eligible customers whose second completed order is at most 90 days after the first, i.e. second_order <= first_order + INTERVAL 90 DAY) and repeat_90d_pct (repeat_90d * 100 / eligible_customers, rounded to 2). Order a customer's orders by order_date, then order_id. Sort by cohort_year.
- Read from the
orderstablenot done yet - Show exactly
cohort_yeareligible_customersrepeat_90drepeat_90d_pctnot done yet - 3 rows, sorted as askednot done yet
Step 3 of 3. Return segment (from customers), customers (customers with at least one completed order), repeat_customers (2 or more completed orders), repeat_rate_pct (repeat_customers * 100 / customers, rounded to 2) and avg_orders (completed orders per customer, rounded to 2). Sort by segment.
- Read from the
customerstablenot done yet - Read from the
orderstablenot done yet - Show exactly
segmentcustomersrepeat_customersrepeat_rate_pctavg_ordersnot done yet
This case unlocks when you finish:
The ticket and lesson are open to read now. Solve every level in those chapters, in any order, and this case opens.
Step 1 · 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.
Step 2 · your query
Loading the editor…
Reference solution
| order_id | customer_id | order_date | status | channel |
|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
Step 3 · 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 Catherine 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
How an analyst reads this ticket
“Do customers come back?” sounds like one number. It becomes a useful number only after three decisions:
- What counts as an order. Here,
completedonly. A cancelled order is not a purchase, and a customer whose only order was refunded did not really start a relationship with the store. - 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.
- 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.
Lantern lit!
Practised: cohortrepeat raterow numbercteright censoring
- Solved
- No hints
- First run
