The request

From Andrew Ramos, Marketing Manager
Your goal
Using orders that have a coupon_code, set result to a DataFrame with columns coupon_code, cancelled, completed, pending, refunded: the number of orders for each coupon code and status, as ints, with 0 where a coupon has no orders in that status. One row per coupon code, sorted A to Z.
- Use the
ordersDataFramenot done yet - Show exactly
coupon_codecancelledcompletedpendingrefundednot done yet - 6 rows, sorted as askednot done yet
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 Andrew 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 |
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 |
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 |
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
A two-level index
Group by two columns and count, and pandas returns a Series whose index has two levels:
counts = orders.groupby(["channel", "status"]).size()
# channel status
# app cancelled 265
# completed 3885
# pending 50
# refunded 120
# marketplace cancelled 108
# ...
That index is a MultiIndex: each entry is a pair like ("app", "completed"). It’s a long table
in disguise, one row per pair.
unstack: from rows to columns
.unstack() takes the innermost index level and turns its values into columns:
counts.unstack()
# status cancelled completed pending refunded
# channel
# app 265 3885 50 120
# marketplace 108 1597 19 43
# web 353 5367 52 141
This is the same grid pivot_table builds; groupby plus unstack is just another route there, handy
when you already have the grouped Series. To move a different level, name it:
counts.unstack("channel") puts channels across the top and statuses down the side.
Missing combinations
A two-key groupby only lists pairs that actually occur. When you unstack, any pair that never happened leaves a hole, filled with NaN:
orders.groupby(["coupon_code", "status"]).size().unstack()
# status cancelled completed pending refunded
# HOLIDAY20 12.0 174.0 NaN 7.0
None of the HOLIDAY20, SPRING15 or SUMMER15 orders is pending, because almost every pending order
was placed in the last days of 2025, after those campaigns ended. For a count, an empty cell is
truly zero, so pass fill_value=0. It fills the holes and keeps the counts as integers.
Notice that orders without a coupon (10,277 of them) don’t appear at all: groupby drops NaN keys by
default. Pass dropna=False to groupby if you want them as their own row.
stack: back to one row per pair
.stack() is the reverse: it moves the columns back into the innermost index level.
grid = counts.unstack()
grid.stack() # the long Series again, one entry per channel and status
stack drops NaN cells by default, so after an unstack and stack you get the original pairs back.
Managing the index
Grids and grouped results keep their keys in the index. Three methods move them around:
long = grid.stack().reset_index(name="n_orders") # index levels → ordinary columns
by_status = long.set_index("status") # a column → the index
by_status.sort_index() # sort rows by their index labels
reset_index() is what you call before merging, melting or exporting, so the keys are real
columns. set_index() goes the other way when you want to look rows up with .loc["completed"].
sort_index() orders rows by label, the way groupby and pivot_table already do.
Common mistakes
- Unstacking without
fill_value=0on counts, leaving NaN and float columns like 174.0. - Unstacking the wrong level. With no argument it’s the last key you grouped by; name the level to be sure.
- Filling with 0 when the cell is an average. An empty average is unknown, not zero.
- Forgetting that
groupbydrops missing keys, then wondering where the no-coupon orders went.
Lantern lit!
Practised: unstackstackmultiindexfill valuegroupby multiple keys
- Solved
- No hints
- First run
