1. Lighthouse

Level 1 · Revenue by category

The request

From Jessica Turner, Merchandising Manager

“Which categories bring in the money? I'd love net revenue per category across all orders, so I can plan next year's buying.”

Your goal

Join order_items to products on product_id. Considering only order lines with quantity > 0 (all order statuses), return category and net_revenue (sum of quantity * unit_price * (1 - discount_pct / 100.0) using the order line's unit_price, rounded to 2), one row per category.

  • Read from the products tablenot done yet
  • Read from the order_items tablenot done yet
  • Show exactly category net_revenuenot done yet

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.

    Tables

    Harbor & Pine store data, ready to query.

    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
    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
    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
    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
    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

    A join lines up rows from two tables that match on a condition. Each order line has a product_id, and each product has one row in products, so you can attach the product’s category to every line:

    SELECT i.order_item_id, i.quantity, p.name, p.category
    FROM order_items AS i
    JOIN products AS p ON p.product_id = i.product_id;

    JOIN on its own means INNER JOIN: only rows that find a match on both sides are kept. An order line whose product is missing from products would disappear. At Harbor & Pine every line has a valid product, but in real warehouses that is never guaranteed, which is why the next level matters.

    Aliases and qualified names

    order_items AS i gives the table a short name. Once a query touches several tables, prefix every column with its table alias (i.quantity, p.category). It is required when both tables have a column of the same name. Here both have unit_price: i.unit_price is the price paid, while p.unit_price is today’s list price. Using the wrong one is a real and common revenue bug. Writing unit_price without a prefix fails with an “ambiguous column” error, which is the database doing you a favor.

    Join, then aggregate

    Joins happen first (they are part of FROM), then WHERE, then GROUP BY. So you can filter and group on columns from either table:

    SELECT p.category,
           ROUND(SUM(i.quantity * i.unit_price * (1 - i.discount_pct / 100.0)), 2) AS net_revenue
    FROM order_items AS i
    JOIN products AS p ON p.product_id = i.product_id
    WHERE i.quantity > 0
    GROUP BY p.category;

    Check the row count

    The single best habit with joins: know how many rows you expect, and check. Joining order lines to products should return exactly one row per order line, because each line matches one product. Compare SELECT COUNT(*) FROM order_items with the count after the join. If the joined count is bigger, the “one” side had duplicates. If it is smaller, some lines had no match.

    Syntax variations

    • USING (product_id) is shorthand for ON a.product_id = b.product_id when the column names are identical. It works in Postgres, DuckDB, MySQL and BigQuery.
    • Old-style comma joins, FROM order_items i, products p WHERE p.product_id = i.product_id, give the same result, but forgetting the WHERE condition creates a cross join with every combination of rows. Write explicit JOIN ... ON instead.
    • NATURAL JOIN matches on every same-named column, which here would include unit_price and silently drop most rows. Avoid it.

    Interview note

    Be ready to explain INNER vs LEFT JOIN with a concrete example, and to say what happens to rows with no match. “An inner join drops them” is the core of it.