1. Lighthouse
  2. Lighthouse

Level 1 · Cost per conversion in SQL

The request

From Jordan Reyes, Head of Marketing

“Finance is asking me to justify the 2025 budget. Can you show how well each traffic source converts, what each converted session cost us per channel, and how paid search moved month by month?”

Your goal

Step 1 of 3. Return source, sessions (number of sessions), conversions (converted sessions) and conversion_rate_pct (conversions * 100 / sessions, rounded to 2). Sort by conversion_rate_pct descending.

  • Read from the web_sessions tablenot done yet
  • Show exactly source sessions conversions conversion_rate_pctnot done yet
  • 5 rows, sorted as askednot done yet

Step 1 · your query

SQL

Loading the editor…

    ResultPreview of web_sessions · press Run to see yours
    Preview of web_sessions
    session_idcustomer_idstarted_atdevicesource
    1NULL2023-01-01 23:02:24mobilepaid
    2182023-01-01 15:07:49desktoppaid
    3NULL2023-01-01 16:55:46desktopdirect
    4NULL2023-01-01 12:43:28mobileorganic

    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 web_sessions · press Run to see yours
      Preview of web_sessions
      session_idcustomer_idstarted_atdevicesource
      1NULL2023-01-01 23:02:24mobilepaid
      2182023-01-01 15:07:49desktoppaid
      3NULL2023-01-01 16:55:46desktopdirect
      4NULL2023-01-01 12:43:28mobileorganic

      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 web_sessions · press Run to see yours
        Preview of web_sessions
        session_idcustomer_idstarted_atdevicesource
        1NULL2023-01-01 23:02:24mobilepaid
        2182023-01-01 15:07:49desktoppaid
        3NULL2023-01-01 16:55:46desktopdirect
        4NULL2023-01-01 12:43:28mobileorganic

        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.

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

        The lesson

        How an analyst reads this ticket

        Jordan needs three numbers per channel: how many visits it brought, how many of those converted, and what it cost. Two tables hold them, and neither is at the grain of the answer:

        • web_sessions has one row per visit. source says where the visit came from, converted says whether it ended in a purchase.
        • marketing_spend has one row per day per channel. Its channel values (paid, email, social) match web_sessions.source, but organic and direct have no spend at all, because nobody pays for them.

        The answer is one row per source (or per source and month). So the plan is: aggregate each table to that grain on its own, then join the two small results.

        Step 1: conversion rate

        One table, one GROUP BY. DuckDB’s COUNT(*) FILTER (WHERE converted) counts only the converted rows; SUM(CASE WHEN converted THEN 1 ELSE 0 END) is the portable spelling. Multiply by 100.0 before dividing so the division is decimal.

        Be careful how you phrase the result. Sessions are not linked to orders in this dataset, so this is a session conversion rate as the web analytics tool reported it, not a count of orders.

        Step 2: the fan-out trap, and the fix

        The tempting query joins the raw tables:

        SELECT w.source, SUM(m.amount) ...
        FROM web_sessions AS w
        JOIN marketing_spend AS m ON m.channel = w.source

        Every paid session matches every paid spend day, so spend is multiplied by the number of sessions. The numbers come out in the billions and nobody notices until the CFO does. Even joining on the date as well still repeats each day’s spend once per session that day.

        The fix is the pattern from the CTE chapter: pre-aggregate before you join. One CTE reduces sessions to one row per source, another reduces spend to one row per channel, and the join is then one-to-one. Start from the sessions side with a LEFT JOIN so organic and direct stay in the answer with NULL spend. Leaving them out would hide the fact that they bring a large share of conversions for free.

        NULLIF(a, b) returns NULL when a equals b, and a otherwise. So NULLIF(c.conversions, 0) turns a zero into NULL, and anything divided by NULL is NULL:

        SELECT 5000 / NULLIF(25, 0) AS per_conversion,   -- 200.0
               5000 / NULLIF(0, 0)  AS no_conversions;   -- NULL, not an error

        A channel with spend and no conversions then gives NULL cost per conversion. Postgres and most warehouses raise a division-by-zero error without it, so write it explicitly.

        Step 3: month by month for paid

        Same recipe, with month added to the grain on both sides. strftime(spend_date, '%Y-%m') makes the text month label ('2025-03'), exactly as in the holiday case; build it the same way on both CTEs so the join keys match. Both CTEs have one row per month, so an inner join is safe here. Monthly figures for paid are noisy because conversions per month are small numbers; look at the direction, not the decimals.

        SQL and pandas side by side

        Idea SQL pandas
        count where true COUNT(*) FILTER (WHERE converted) ("converted", "sum")
        pre-aggregate a CTE with GROUP BY groupby(...).agg(...) into a new frame
        keep sources without spend LEFT JOIN merge(how="left")
        safe division NULLIF(x, 0) dividing by 0 gives inf; replace it with NaN

        Pitfalls

        • Joining raw tables at different grains. Always ask “how many rows per key does each side have?” before a join.
        • Missing spend days. Twelve day/channel rows are missing from marketing_spend because of tracking outages. Totals are slightly low; say so in the write-up rather than inventing values.
        • Filtering the year on one side only. Both tables need the 2025 filter, or spend from three years is divided by conversions from one.
        • Attribution. “Source of the session” is last-touch attribution. It is the usual starting point and the usual first question from Finance; mention it.