1. Lighthouse
  2. Lighthouse

Level 2 · Holiday season review in pandas

The request

From Megan Collins, Head of Sales

“Priya wants the 2025 holiday review in a notebook so the team can extend it. Please build it in pandas, and make sure every figure matches the SQL version of this case.”

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 orders DataFramenot done yet
  • Use the order_items DataFramenot done yet
  • Show exactly month orders net_revenue avg_order_valuenot done yet

Step 1 · your code

Python

Loading the editor…

    ResultPreview of orders · press Run to see yours
    Preview of orders
    order_idcustomer_idorder_datestatuschannel
    10011252023-01-01 20:51:19completedweb
    10021562023-01-02 07:58:46completedweb
    1003222023-01-02 10:25:27completedmarketplace
    1004482023-01-02 11:21:51completedmarketplace

    Mote checks your result

    Press Run. Each row that matches the goal becomes a firefly in your lantern.

    Step 2 · your code

    Python

    Loading the editor…

      ResultPreview of products · press Run to see yours
      Preview of products
      product_idnamecategorysubcategoryunit_price
      101Vale Velvet LoveseatFurnitureSofas1659.00
      102Alder Oak Coffee TableFurnitureTables919.00
      103Orchard Rattan Dining ChairFurnitureChairs789.00
      104Upland Oak BookcaseFurnitureStorage429.00

      Mote checks your result

      Press Run. Each row that matches the goal becomes a firefly in your lantern.

      Step 3 · your code

      Python

      Loading the editor…

        ResultPreview of orders · press Run to see yours
        Preview of orders
        order_idcustomer_idorder_datestatuschannel
        10011252023-01-01 20:51:19completedweb
        10021562023-01-02 07:58:46completedweb
        1003222023-01-02 10:25:27completedmarketplace
        1004482023-01-02 11:21:51completedmarketplace

        Mote checks your result

        Press Run. Each row that matches the goal becomes a firefly in your lantern.

        DataFrames

        Loaded for you as pandas DataFrames, plus pd and np.

        orders12,000 rows · 7 columns
        • order_idinteger
        • customer_idinteger
        • order_datetimestamp
        • statusvarchar
        • channelvarchar
        • shipping_feedecimal
        • coupon_codevarchar
        First 5 rows of orders
        order_idcustomer_idorder_datestatuschannelshipping_feecoupon_code
        10011252023-01-01 20:51:19completedweb0.00NaN
        10021562023-01-02 07:58:46completedweb0.00NaN
        1003222023-01-02 10:25:27completedmarketplace4.99WELCOME10
        1004482023-01-02 11:21:51completedmarketplace4.99WELCOME10
        1005822023-01-02 11:43:36completedweb0.00WELCOME10
        order_items30,338 rows · 6 columns
        • order_item_idinteger
        • order_idinteger
        • product_idinteger
        • quantityinteger
        • unit_pricedecimal
        • discount_pctdecimal
        First 5 rows of order_items
        order_item_idorder_idproduct_idquantityunit_pricediscount_pct
        11001121931.990.00
        21001154311.990.00
        31002154411.990.00
        410021941149.000.00
        51002193219.990.00
        products120 rows · 8 columns
        • product_idinteger
        • namevarchar
        • categoryvarchar
        • subcategoryvarchar
        • unit_pricedecimal
        • unit_costdecimal
        • launched_ondate
        • is_activeboolean
        First 5 rows of products
        product_idnamecategorysubcategoryunit_priceunit_costlaunched_onis_active
        101Vale Velvet LoveseatFurnitureSofas1659.00NaN2023-09-11true
        102Alder Oak Coffee TableFurnitureTables919.00503.502024-05-16true
        103Orchard Rattan Dining ChairFurnitureChairs789.00429.872023-05-27true
        104Upland Oak BookcaseFurnitureStorage429.00214.702022-12-20true
        105Avery Leather Bed FrameFurnitureBeds449.00232.702021-08-31true
        customers2,018 rows · 9 columns
        • customer_idinteger
        • first_namevarchar
        • last_namevarchar
        • emailvarchar
        • cityvarchar
        • statevarchar
        • signup_datedate
        • segmentvarchar
        • referral_sourcevarchar
        First 5 rows of customers
        customer_idfirst_namelast_nameemailcitystatesignup_datesegmentreferral_source
        1OwenHudsonowen.hudson@icloud.comSan FranciscoCA2022-06-02consumersearch
        2DouglasMendozadmendoza69@gmail.comAtlantaGA2022-06-02consumerNaN
        3HenryBrookshbrooks@lakesidecafe.comLos AngelesCA2022-06-04businessNaN
        4BrianGrahambgraham@bluecedarhomes.comSeattleWA2022-06-06businesspinterest
        5KyleJimenezkjimenez27@yahoo.comIndianapolisIN2022-06-07consumerNaN
        returns1,673 rows · 5 columns
        • return_idinteger
        • order_item_idinteger
        • returned_ondate
        • reasonvarchar
        • refund_amountdecimal
        First 5 rows of returns
        return_idorder_item_idreturned_onreasonrefund_amount
        1142023-01-10damaged83.69
        2422023-01-10wrong_item19.99
        31052023-01-14wrong_item10.99
        4242023-01-16changed_mind26.99
        51262023-01-16late20.99
        employees60 rows · 8 columns
        • employee_idinteger
        • first_namevarchar
        • last_namevarchar
        • departmentvarchar
        • titlevarchar
        • manager_idinteger
        • hire_datedate
        • salaryinteger
        First 5 rows of employees
        employee_idfirst_namelast_namedepartmenttitlemanager_idhire_datesalary
        1CatherineHaleExecutiveChief Executive OfficerNaN2016-03-01285000
        2RobertKimExecutiveChief Operating Officer12017-01-09235000
        3MeganCollinsSalesHead of Sales12018-04-16172000
        4ChloeGutierrezSalesAccount Executive32020-01-2172000
        5FrankBellSalesAccount Executive32023-05-2173000
        web_sessions40,000 rows · 7 columns
        • session_idinteger
        • customer_idinteger
        • started_attimestamp
        • devicevarchar
        • sourcevarchar
        • pages_viewedinteger
        • convertedboolean
        First 5 rows of web_sessions
        session_idcustomer_idstarted_atdevicesourcepages_viewedconverted
        1NaN2023-01-01 23:02:24mobilepaid1false
        2182023-01-01 15:07:49desktoppaid1false
        3NaN2023-01-01 16:55:46desktopdirect7false
        4NaN2023-01-01 12:43:28mobileorganic3false
        5NaN2023-01-01 13:06:21mobilesocial2false
        marketing_spend3,276 rows · 3 columns
        • spend_datedate
        • channelvarchar
        • amountdecimal
        First 5 rows of marketing_spend
        spend_datechannelamount
        2023-01-01paid492.99
        2023-01-01email37.38
        2023-01-01social200.53
        2023-01-02paid709.13
        2023-01-02email64.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_table sorts the index alphabetically; the task asks for growth order, so sort explicitly.
        • Forgetting reset_index. A result with category as 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_table would leave a missing value (NaN) in that cell. Here every category sells in both seasons, but check before you divide.