The request

From Jordan Reyes, Head of Marketing
Your goal
Step 1 of 3. Return source, sessions (number of sessions), conversions (converted sessions) and conversion_rate_pct (conversions * 100 / sessions, rounded to 2). Sort by conversion_rate_pct descending.
- Read from the
web_sessionstablenot done yet - Show exactly
sourcesessionsconversionsconversion_rate_pctnot done yet - 5 rows, sorted as askednot done yet
Step 2 of 3. Return one row for every session source with: source, conversions, spend (total 2025 marketing_spend.amount for that channel, rounded to 2; NULL when the source has no spend) and cost_per_conversion (spend / conversions, rounded to 2; NULL when there is no spend). Sort by source ascending.
- Read from the
web_sessionstablenot done yet - Read from the
marketing_spendtablenot done yet - Show exactly
sourceconversionsspendcost_per_conversionnot done yet
Step 3 of 3. For the paid channel only (web_sessions.source = 'paid', marketing_spend.channel = 'paid'), return month (text 'YYYY-MM'), spend (rounded to 2), sessions, conversions and cost_per_conversion (spend / conversions, rounded to 2; NULL in a month with no conversions). One row per month of 2025, sorted by month.
- Read from the
web_sessionstablenot done yet - Read from the
marketing_spendtablenot 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 query
Loading the editor…
Reference solution
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NULL | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NULL | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NULL | 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 query
Loading the editor…
Reference solution
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NULL | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NULL | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NULL | 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 query
Loading the editor…
Reference solution
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NULL | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NULL | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NULL | 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
Tables
Harbor & Pine store data, ready to query.
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 |
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 |
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 |
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 |
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 |
The lesson
How an analyst reads this ticket
Jordan needs three numbers per channel: how many visits it brought, how many of those converted, and what it cost. Two tables hold them, and neither is at the grain of the answer:
web_sessionshas one row per visit.sourcesays where the visit came from,convertedsays whether it ended in a purchase.marketing_spendhas one row per day per channel. Itschannelvalues (paid,email,social) matchweb_sessions.source, butorganicanddirecthave no spend at all, because nobody pays for them.
The answer is one row per source (or per source and month). So the plan is: aggregate each table to that grain on its own, then join the two small results.
Step 1: conversion rate
One table, one GROUP BY. DuckDB’s COUNT(*) FILTER (WHERE converted) counts only the converted rows;
SUM(CASE WHEN converted THEN 1 ELSE 0 END) is the portable spelling. Multiply by 100.0 before
dividing so the division is decimal.
Be careful how you phrase the result. Sessions are not linked to orders in this dataset, so this is a session conversion rate as the web analytics tool reported it, not a count of orders.
Step 2: the fan-out trap, and the fix
The tempting query joins the raw tables:
SELECT w.source, SUM(m.amount) ...
FROM web_sessions AS w
JOIN marketing_spend AS m ON m.channel = w.source
Every paid session matches every paid spend day, so spend is multiplied by the number of sessions. The numbers come out in the billions and nobody notices until the CFO does. Even joining on the date as well still repeats each day’s spend once per session that day.
The fix is the pattern from the CTE chapter: pre-aggregate before you join. One CTE reduces
sessions to one row per source, another reduces spend to one row per channel, and the join is then
one-to-one. Start from the sessions side with a LEFT JOIN so organic and direct stay in the
answer with NULL spend. Leaving them out would hide the fact that they bring a large share of
conversions for free.
NULLIF(a, b) returns NULL when a equals b, and a otherwise. So NULLIF(c.conversions, 0)
turns a zero into NULL, and anything divided by NULL is NULL:
SELECT 5000 / NULLIF(25, 0) AS per_conversion, -- 200.0
5000 / NULLIF(0, 0) AS no_conversions; -- NULL, not an error
A channel with spend and no conversions then gives NULL cost per conversion. Postgres and most
warehouses raise a division-by-zero error without it, so write it explicitly.
Step 3: month by month for paid
Same recipe, with month added to the grain on both sides. strftime(spend_date, '%Y-%m') makes
the text month label ('2025-03'), exactly as in the holiday case; build it the same way on both
CTEs so the join keys match. Both CTEs have one row per month, so an
inner join is safe here. Monthly figures for paid are noisy because conversions per month are small
numbers; look at the direction, not the decimals.
SQL and pandas side by side
| Idea | SQL | pandas |
|---|---|---|
| count where true | COUNT(*) FILTER (WHERE converted) |
("converted", "sum") |
| pre-aggregate | a CTE with GROUP BY |
groupby(...).agg(...) into a new frame |
| keep sources without spend | LEFT JOIN |
merge(how="left") |
| safe division | NULLIF(x, 0) |
dividing by 0 gives inf; replace it with NaN |
Pitfalls
- Joining raw tables at different grains. Always ask “how many rows per key does each side have?” before a join.
- Missing spend days. Twelve day/channel rows are missing from
marketing_spendbecause of tracking outages. Totals are slightly low; say so in the write-up rather than inventing values. - Filtering the year on one side only. Both tables need the 2025 filter, or spend from three years is divided by conversions from one.
- Attribution. “Source of the session” is last-touch attribution. It is the usual starting point and the usual first question from Finance; mention it.
Lantern lit!
Practised: ctepre aggregateleft joinnullifconversion ratecost per conversion
- Solved
- No hints
- First run
