The request

From Megan Collins, Head of Sales
Your goal
Step 1 of 3. Set result to a DataFrame with columns month (text 'YYYY-MM'), orders (distinct completed orders), net_revenue (rounded to 2) and avg_order_value (net revenue / orders, rounded to 2) for September to December 2025, sorted by month, with a default index.
- Use the
ordersDataFramenot done yet - Use the
order_itemsDataFramenot done yet - Show exactly
monthordersnet_revenueavg_order_valuenot done yet
Step 2 of 3. Set result to a DataFrame with columns category, rev_2024, rev_2025 (net revenue from November 1 to December 31 of each year, rounded to 2) and yoy_change_pct ((rev_2025 - rev_2024) * 100 / rev_2024, from unrounded sums, rounded to 2), sorted by yoy_change_pct descending, with a default index.
- Use the
productsDataFramenot done yet - Use the
ordersDataFramenot done yet - Show exactly
categoryrev_2024rev_2025yoy_change_pctnot done yet
Step 3 of 3. Set result to a DataFrame with columns channel, nov_revenue and dec_revenue (net revenue in November and December 2025, rounded to 2) and mom_change_pct ((dec - nov) * 100 / nov, from unrounded sums, rounded to 2), sorted by channel ascending, with a default index.
- Use the
ordersDataFramenot done yet - Use the
order_itemsDataFramenot done yet - Show exactly
channelnov_revenuedec_revenuemom_change_pctnot 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
| 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 2 · your code
Loading the editor…
Reference solution
| product_id | name | category | subcategory | unit_price |
|---|---|---|---|---|
| 101 | Vale Velvet Loveseat | Furniture | Sofas | 1659.00 |
| 102 | Alder Oak Coffee Table | Furniture | Tables | 919.00 |
| 103 | Orchard Rattan Dining Chair | Furniture | Chairs | 789.00 |
| 104 | Upland Oak Bookcase | Furniture | Storage | 429.00 |
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
| 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.
What Megan gets, and why
DataFrames
Loaded for you as pandas DataFrames, plus pd and np.
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 |
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 |
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 |
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 |
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 |
The lesson
Same question, same definitions
This is the same review as the SQL level of this case, built in pandas. Keep the definitions
identical or the numbers will not match: completed orders, lines with quantity > 0, revenue
quantity * unit_price * (1 - discount_pct / 100), shipping excluded. When a SQL and a pandas
version of the same report disagree, nine times out of ten one of them filtered differently.
Build the base table once
In SQL you repeat the join in every query. In pandas you build one line-level table and reuse it:
completed = orders[orders["status"] == "completed"]
lines = order_items[order_items["quantity"] > 0].merge(
completed[["order_id", "order_date", "channel"]],
on="order_id", how="inner", validate="many_to_one",
)
lines["revenue"] = lines["quantity"] * lines["unit_price"] * (1 - lines["discount_pct"] / 100)
Filtering orders before the merge is the pandas version of WHERE o.status = 'completed': the inner
merge drops every line whose order is not in completed. validate="many_to_one" makes pandas
raise if an order id is ever duplicated, which is how you would catch a fan-out before it reaches a
slide.
Three date and label tools this case uses
Month labels with .dt.strftime. On a datetime column, .dt.strftime("%Y-%m") returns text
like "2025-11". %Y is the four-digit year and %m the two-digit month. Text in that shape sorts
in calendar order, so it makes a good groupby key.
orders["order_date"].dt.strftime("%Y-%m").head(2)
# 0 2023-01
# 1 2023-01
Month numbers with .dt.month. .dt.month gives the month as a number, 1 to 12, so
lines["order_date"].dt.month == 11 is True for November rows.
Labels from a condition with np.where. np.where(condition, value_if_true, value_if_false)
builds a column row by row, like a two-branch if:
orders["shipping_type"] = np.where(orders["shipping_fee"] == 0, "free shipping", "paid shipping")
Every row whose fee is 0 gets "free shipping", every other row "paid shipping". In this case
you use it to label each line with its season or month, then pivot on that label.
Finally, sort_index() sorts a DataFrame by its index, which after a pivot is the row key
(channel in step 3), so it is the same as sorting by channel before reset_index().
Step 1: groupby with named aggregation
COUNT(DISTINCT order_id) becomes ("order_id", "nunique"). Using "count" instead would count
lines, the same mistake as COUNT(*) in SQL. Average order value is a ratio of two aggregates, so
compute it after the groupby, from the unrounded revenue, and round the columns last.
Date filters compare a datetime64 column with a string: pandas parses "2026-01-01" as midnight,
so < "2026-01-01" keeps every timestamp on December 31.
Steps 2 and 3: two periods as columns
SQL’s SUM(CASE WHEN period THEN revenue END) has a direct pandas equivalent: label every row with
its period, then pivot.
holiday = lines[in_24 | in_25].copy()
holiday["season"] = np.where(holiday["order_date"] < "2025-01-01", "rev_2024", "rev_2025")
wide = holiday.pivot_table(index="category", columns="season", values="revenue", aggfunc="sum")
The result has category as its index and one column per season. Two clean-up lines make it match
the SQL output: wide.columns.name = None removes the leftover “season” label on the columns, and
reset_index() turns the category index back into a column. Naming the period labels after the
final column names (rev_2024, nov_revenue) saves a rename.
An alternative is groupby(["category", "season"])["revenue"].sum().unstack(). It produces the same
wide table; pivot_table just says it in one call.
SQL and pandas side by side
| Step | SQL | pandas |
|---|---|---|
| filter rows | WHERE before GROUP BY |
boolean mask before groupby |
| distinct count | COUNT(DISTINCT ...) |
nunique |
| periods side by side | SUM(CASE WHEN ...) |
pivot_table(columns=...) |
| sort | ORDER BY ... DESC |
sort_values(..., ascending=False) |
Pitfalls
- Rounding before dividing. Round the revenue columns after computing the growth, exactly as the SQL version does in its outer SELECT.
- Category order.
pivot_tablesorts the index alphabetically; the task asks for growth order, so sort explicitly. - Forgetting
reset_index. A result withcategoryas its index looks the same in a notebook but is not the table the task asks for. - Missing periods. If a category had no sales in one season,
pivot_tablewould leave a missing value (NaN) in that cell. Here every category sells in both seasons, but check before you divide.
Lantern lit!
Practised: mergegroupbynamed aggregationnuniquepivot tableyoymom
- Solved
- No hints
- First run
