The request

From Jordan Reyes, Head of Marketing
Your goal
Step 1 of 3. Set result to a DataFrame with columns source, sessions, conversions and conversion_rate_pct (conversions * 100 / sessions, rounded to 2), sorted by conversion_rate_pct descending, with a default index.
- Use the
web_sessionsDataFramenot done yet - Show exactly
sourcesessionsconversionsconversion_rate_pctnot done yet - 5 rows, sorted as askednot done yet
Step 2 of 3. Set result to a DataFrame with one row per session source and columns source, conversions, spend (total 2025 spend for that channel, rounded to 2; NaN when the source has no spend) and cost_per_conversion (spend / conversions, rounded to 2; NaN when there is no spend), sorted by source ascending, with a default index.
- Use the
web_sessionsDataFramenot done yet - Use the
marketing_spendDataFramenot done yet - Show exactly
sourceconversionsspendcost_per_conversionnot done yet
Step 3 of 3. For the paid channel only, set result to a DataFrame with columns month (text 'YYYY-MM'), spend (rounded to 2), sessions, conversions and cost_per_conversion (spend / conversions, rounded to 2; NaN in a month with no conversions), one row per month of 2025, sorted by month, with a default index.
- Use the
web_sessionsDataFramenot done yet - Use the
marketing_spendDataFramenot done yet - Show exactly
monthspendsessionsconversionscost_per_conversionnot 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 code
Loading the editor…
Reference solution
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
Step 2 · your code
Loading the editor…
Reference solution
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
Step 3 · your code
Loading the editor…
Reference solution
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
What Jordan gets, and why
DataFrames
Loaded for you as pandas DataFrames, plus pd and np.
web_sessions40,000 rows · 7 columns
session_idintegercustomer_idintegerstarted_attimestampdevicevarcharsourcevarcharpages_viewedintegerconvertedboolean
| session_id | customer_id | started_at | device | source | pages_viewed | converted |
|---|---|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid | 1 | false |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid | 1 | false |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct | 7 | false |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic | 3 | false |
| 5 | NaN | 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 |
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 | NaN |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles | CA | 2022-06-04 | business | NaN |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle | WA | 2022-06-06 | business | |
| 5 | Kyle | Jimenez | kjimenez27@yahoo.com | Indianapolis | IN | 2022-06-07 | consumer | NaN |
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 | NaN | 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 |
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 | NaN |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web | 0.00 | NaN |
| 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 |
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 | NaN | 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 |
The lesson
Same question, same definitions
This is Jordan’s budget analysis in pandas, with the same definitions as the SQL level of this case: 2025 only on
both tables, a conversion is a session with converted == True, and spend is matched to sessions by
channel name. Organic and direct have sessions but no spend.
Step 1: counting True values
converted is a boolean column. In pandas True behaves as 1 and False as 0, so
("converted", "sum") counts conversions and ("converted", "mean") would give the rate directly.
The task asks for a percentage rounded to 2, so compute it from the two counts:
by_source = ws.groupby("source", as_index=False).agg(
sessions=("session_id", "count"), conversions=("converted", "sum")
)
by_source["conversion_rate_pct"] = (by_source["conversions"] * 100 / by_source["sessions"]).round(2)
"count" counts non-missing values of the named column. session_id is never missing, so it counts
every session. Counting customer_id would be a quiet bug: anonymous sessions have NaN there and
would not be counted.
Step 2: pre-aggregate, then merge
In SQL this takes two CTEs. In pandas each one is simply a DataFrame:
conv = ws.groupby("source", as_index=False).agg(conversions=("converted", "sum"))
spend = ms.groupby("channel", as_index=False).agg(spend=("amount", "sum")).rename(columns={"channel": "source"})
table = conv.merge(spend, on="source", how="left", validate="one_to_one")
marketing_spend calls its key channel while web_sessions calls it source.
.rename(columns={"channel": "source"}) renames a column (old name on the left, new name on the
right) so both frames share the key and on="source" works. left_on="source", right_on="channel"
does the same job without renaming.
validate="one_to_one" is the pandas way of asserting what you reasoned about in SQL: after the
groupby, each side has one row per key, so the merge cannot fan out. If someone later merges the raw
tables by mistake, pandas raises a MergeError instead of handing back inflated spend.
how="left" from the sessions side keeps organic and direct, with NaN spend (pandas’ marker
for a missing number). Any arithmetic with NaN gives NaN, so their cost per conversion is
missing, which is correct: it is not zero, it is not applicable.
Division by zero
pandas silently returns inf (infinity) when you divide a number by zero. The fix is to turn zeros
in the denominator into missing values first. .replace(0, np.nan) swaps every 0 in a Series for
np.nan, numpy’s missing number, and anything divided by NaN is NaN:
pd.Series([5000, 5000]) / pd.Series([25, 0]) # 200.0, inf
pd.Series([5000, 5000]) / pd.Series([25, 0]).replace(0, np.nan) # 200.0, NaN
No 2025 channel or month has zero conversions in this data, but a monthly report that runs every
month will meet one eventually, and inf in a chart is hard to explain.
Step 3: add month to both keys
Build a month label on each table with .dt.strftime("%Y-%m"), which turns a datetime into text
like "2025-03" (%Y is the year, %m the two-digit month). Group each table by month, and merge
on month. The grain is now one row per month on both sides.
SQL and pandas side by side
| Step | SQL | pandas |
|---|---|---|
| conversions | COUNT(*) FILTER (WHERE converted) |
("converted", "sum") |
| CTE | WITH conv AS (...) |
conv = ws.groupby(...) |
| key names differ | ON s.channel = c.source |
rename or left_on/right_on |
| assert no fan-out | reason about it | validate="one_to_one" |
NULLIF(x, 0) |
built in | x.replace(0, np.nan) |
Pitfalls
- Merging raw sessions with raw spend. Even on
["source", "date"], each day’s spend repeats once per session that day. - Rounding spend before dividing. Compute cost per conversion from the raw sum, then round.
- Missing spend days. Twelve outage rows are absent from
marketing_spend; the totals are a slight undercount, the same in SQL and pandas. - Dates as strings. The filters compare a
datetime64column with"2025-01-01"; pandas parses the string, so no conversion is needed.
Lantern lit!
Practised: groupbynamed aggregationpre aggregateleft mergeconversion ratecost per conversion
- Solved
- No hints
- First run
