1. Lighthouse
  2. Lighthouse

Level 1 · Holiday season review in SQL

The request

From Megan Collins, Head of Sales

“Before the January kickoff I need the holiday story for 2025: how the season compared with the autumn months, which categories grew versus the 2024 holidays, and whether December beat November in each channel.”

Your goal

Step 1 of 3. For September to December 2025, return month (text 'YYYY-MM', use strftime(order_date, '%Y-%m')), orders (number of distinct completed orders with at least one counted line), net_revenue (rounded to 2) and avg_order_value (net revenue / orders, rounded to 2). Sort by month ascending.

  • Read from the orders tablenot done yet
  • Read from the order_items tablenot done yet
  • Show exactly month orders net_revenue avg_order_valuenot done yet

Step 1 · your query

SQL

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 query

    SQL

    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 query

      SQL

      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.

        Tables

        Harbor & Pine store data, ready to query.

        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.00NULL
        10021562023-01-02 07:58:46completedweb0.00NULL
        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.00NULL2023-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-02consumerNULL
        3HenryBrookshbrooks@lakesidecafe.comLos AngelesCA2022-06-04businessNULL
        4BrianGrahambgraham@bluecedarhomes.comSeattleWA2022-06-06businesspinterest
        5KyleJimenezkjimenez27@yahoo.comIndianapolisIN2022-06-07consumerNULL
        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 OfficerNULL2016-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
        1NULL2023-01-01 23:02:24mobilepaid1false
        2182023-01-01 15:07:49desktoppaid1false
        3NULL2023-01-01 16:55:46desktopdirect7false
        4NULL2023-01-01 12:43:28mobileorganic3false
        5NULL2023-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

        How an analyst reads this ticket

        Megan’s message contains three questions at three different cuts of the data: time, category and channel. Before writing any SQL, pin down the one definition all three share, because a review in which the monthly total and the category total disagree is worse than no review:

        • Revenue = net line revenue: quantity * unit_price * (1 - discount_pct / 100.0). discount_pct is stored in percentage points (15 means 15%), so dividing by 100 is not optional.
        • Which orders: completed only. Cancelled orders never shipped, refunded orders were paid back and pending orders may still fail. Including them inflates December the most, because pending orders cluster in the last days of the year.
        • Which lines: quantity > 0. A handful of lines have zero or negative quantities from data entry errors.
        • Not revenue: shipping_fee. It lives on orders, one per order, and has its own question.

        Write the definition into the top of your notebook or query file. Then each step is just a different GROUP BY on the same base.

        Two tools this case uses

        Month labels with strftime. strftime(date, format) turns a date or timestamp into text in the shape you ask for. %Y is the four-digit year and %m the two-digit month:

        SELECT order_id, order_date, strftime(order_date, '%Y-%m') AS month
        FROM orders
        ORDER BY order_id
        LIMIT 2;
        order_id order_date month
        1001 2023-01-01 20:51:19 2023-01
        1002 2023-01-02 07:58:46 2023-01

        Because '2025-09' sorts before '2025-10' as text, ORDER BY month puts these labels in calendar order. You can GROUP BY the alias month in DuckDB.

        Naming a step with WITH. A CTE (common table expression) gives a query a name so the next query can read from it like a table. It is handy when you want to aggregate first and work with the totals after:

        WITH by_month AS (
          SELECT strftime(order_date, '%Y-%m') AS month, COUNT(*) AS orders
          FROM orders
          WHERE status = 'completed'
          GROUP BY month
        )
        SELECT month, orders
        FROM by_month
        WHERE orders > 600
        ORDER BY month;

        The part in brackets runs first and produces a small table called by_month, one row per month; the SELECT after it filters that table and returns three busy months: 2024-12 (629), 2025-11 (651) and 2025-12 (634). Steps 2 and 3 use exactly this: a CTE holds the unrounded revenue per period, and the outer SELECT rounds and computes the growth.

        Step 1: the season against the autumn

        The base is order_items joined to orders. Each line has exactly one order, so the join adds the status and date without adding rows. Counting orders is where the grain bites: after the join there is one row per line, so COUNT(*) counts lines, not orders. COUNT(DISTINCT o.order_id) is the fix, and average order value is revenue divided by that distinct count.

        Grouping by strftime(o.order_date, '%Y-%m') gives sortable text months. Filter the date with a half-open range (>= '2025-09-01' AND < '2026-01-01') so the last evening of December, a timestamp like 2025-12-31 21:14, is not lost.

        Step 2: year over year by category

        Comparing two periods side by side is conditional aggregation: one SUM(CASE WHEN ... END) per period. Rows outside both seasons give NULL inside the CASE and SUM ignores them. A CTE keeps the raw sums unrounded so the growth percentage is calculated on exact values; rounding first and then dividing can move the last decimal.

        Holiday growth is a year-over-year question, not a month-over-month one: comparing December with November tells you about the calendar, comparing December 2025 with December 2024 tells you about the business.

        Step 3: did December beat November?

        Same pattern, two months, grouped by channel. Expect December to be close to November: Black Friday and Cyber Monday land in late November, while December has the HOLIDAY20 push and a dead Christmas Eve and Day. Which channel grew is the useful part of the answer.

        SQL and pandas side by side

        Idea SQL pandas
        line revenue expression inside SUM a new column with assign
        month label strftime(order_date, '%Y-%m') order_date.dt.strftime('%Y-%m')
        distinct orders COUNT(DISTINCT order_id) ("order_id", "nunique")
        two periods as columns SUM(CASE WHEN ...) filter, then pivot_table

        Pitfalls

        • Joining orders to order_items and then summing shipping_fee counts each fee once per line.
        • BETWEEN '2025-12-01' AND '2025-12-31' on a timestamp stops at midnight on the 31st.
        • Integer division is not a risk here because the expression is already a decimal, but multiplying by 100.0 rather than 100 keeps percentage maths safe everywhere.