DB Olympics

Queries · q288 · VP Product · week 2, night 1

Cart bleed trend by month, 2025

“Is the cart-stage drop-off getting better, worse, or staying flat over the past year?”

Manager · asked the questionJonas FeldVP Product. Joined from a mobile-first retailer. Deciding the 2026 roadmap.
Analyst · planned the reportEmeka ObiCustomer-centric: follows cohorts of shoppers or merchants over time.
Engineer · wrote the SQLViktorOld-school warehouse engineer: explicit JOIN chains straight to the base tables, derived tables, GROUP BY with CASE pivots, few CTEs.

Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told

  1. A business event lands on the VP Product's desk

    Week 1

    We are redesigning the shopper journey for 2026. We need to understand the whole path, from the first visit to checkout, delivery, returns and the next purchase, and where shoppers drop out and why.

  2. Jonas Feld writes to Emeka Obi

    The manager, at the start of week 2

    Subject: Good start — let's dig into the cart leak

    Emeka, this is exactly the kind of clarity I needed. The cart-stage bleed is the headline, and I agree we should lead with it — but a two-thirds drop-off that lingers for days, converting at under 20%, isn't something we can act on until we know why people stall. "Add cart recovery emails" is a guess until we understand the shape of the hesitation. That's my main ask for this week.

    I also want to flag one thing before we build a roadmap slide on repeat purchase and returns: the return-vs-repeat gap and the delivery/returns pattern are both flagged as not-yet-causal, confounded by vertical. I don't want to present an 8-point "returns hurt loyalty" number to the exec team if it's really just "apparel behaves differently." Let's firm that up before it goes in a deck, but it's not urgent this week.

    Questions for this week:

    1. What does a stalled cart actually contain, and does it differ from a fast-converting one? I'm thinking about basket size, price point, discount/coupon presence — anything that hints at why someone hesitates for hours or days instead of minutes.
    2. Is the cart bleed getting better, worse, or flat over the past year? If it's been stable at ~70% for two years, that's a structural product problem; if it's worsening recently, something changed (a checkout step, a fee, a release) that we should find and reverse.
    3. Of the carts that eventually do convert after sitting a while, is there a nudge already happening (a reminder, a return visit, a discount) that we could learn from and do more deliberately? I want to know if recovery is already happening organically before we invest in prompting it.
    4. On the subscription merchants' tracking gap — is this just a blind spot in how we instrument recurring orders, or is it telling us something about how those shoppers actually behave (e.g., they never browse again after the first signup)? A quick gut-check is enough this week; no need for a deep investigation yet.

    Keep the returns/delivery vertical-control work and the causal retention question on the list for whenever a slot frees up, but the cart story is what I want to walk into the roadmap review with real conviction on.

    Thanks, Emeka — strong week.

    Jonas

  3. Emeka Obi plans the night

    The analyst · week 2, night 1, request 2 of 3

    Jonas's email has an implicit priority: Q1 (what's in a stalled cart) and Q2 (is the bleed trending worse) are "my main ask for this week"; Q3 (organic recovery) is also wanted but reads as a natural follow-up once we understand the shape; Q4 is explicitly "a quick gut-check... no need for deep investigation." So tonight I'm doing Q1, Q2, and a lightweight version of Q4, saving Q3 for night 2 since it benefits from what we learn tonight (e.g., whether stalled carts later convert with a discount attached is itself a clue about organic recovery).

    Design notes:

    • For Q1, carts.item_count and carts.subtotal are populated regardless of cart status, so I can compare basket size across ALL carts by duration bucket. Discount/coupon presence, however, only exists on the order side (order_discounts/coupon_redemptions) — there's nothing on an abandoned cart to show a discount was being attempted. So I've scoped the discount question to "among carts that did convert, how often did the resulting order carry a discount, by how long the cart took to convert" — flagged clearly as a narrower sub-question, not a true stalled-vs-fast comparison on discounts.
    • For Q2, I'm using the same 5 duration buckets from last week's cart-timing reports, by month across all of 2025, to see if the mix is shifting (e.g., more carts falling into the 1-7 day "bleed" bucket recently) or if the overall cart→order conversion rate itself is moving. I'll also watch for anything around November 2025, since Platform Engineering flagged a cart-reminder email bug that week — relevant context, not something I'm acting on yet.
    • For Q4 ("gut check"), I'm testing the blind-spot-vs-behavior question directly: for the 4 subscription merchants, do customers have tracked sessions before their first order (proving tracking works) but little or none after (suggesting they genuinely stop browsing once subscribed) — vs. near-zero sessions even before the first order (which would point to a tracking gap instead).

    All three use the 6 retail tenants (Q1, Q2) or 4 subscription tenants (Q4) established in last week's work, so the engineer has a concrete, named population to filter on rather than re-deriving "top merchants."

    Cart bleed trend by month, 2025
    Question
    Is the cart-stage drop-off getting better, worse, or staying flat over the past year?
    One row per
    calendar month of cart creation, 2025
    Columns
    • month
    • carts created
    • overall cart-to-order conversion pct
    • pct resolved within 1 hour
    • pct resolved in 1-7 days
    • median days to convert (converted carts only)
  4. Viktor writes the SQL

    The engineer · 2 dry runs on the empty schema, then the submission

    Dry run 1 · STATUS: REJECTED
    SELECT
      to_char(date_trunc('month', c.created_at), 'YYYY-MM') AS month,
      COUNT(*) AS carts_created,
      ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*), 1) AS overall_conversion_pct,
      ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*), 1) AS pct_resolved_within_1_hour,
      ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*), 1) AS pct_resolved_1_7_days,
      ROUND(CAST(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (c.converted_at - c.created_at))/86400.0) FILTER (WHERE c.status = 'converted') AS numeric), 1) AS median_days_to_convert
    FROM carts c
    JOIN tenants t ON t.id = c.tenant_id
    WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
      AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
    GROUP BY date_trunc('month', c.created_at)
    ORDER BY date_trunc('month', c.created_at)
    Dry run 2 · 3 changes from the dry run before · STATUS: COMPILES (dry run on the empty schema, no data read)
    SELECT  to_char(date_trunc('month', c.created_at), 'YYYY-MM') AS month,  cd.month_label AS month,  COUNT(*) AS carts_created,  ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*), 1) AS overall_conversion_pct,  ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*), 1) AS pct_resolved_within_1_hour,  ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*), 1) AS pct_resolved_1_7_days,  ROUND(CAST(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (c.converted_at - c.created_at))/86400.0) FILTER (WHERE c.status = 'converted') AS numeric), 1) AS median_days_to_convertFROM carts cJOIN tenants t ON t.id = c.tenant_idJOIN calendar_days cd ON cd.calendar_date = CAST(c.created_at AS date)WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')  AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'GROUP BY date_trunc('month', c.created_at)ORDER BY date_trunc('month', c.created_at)GROUP BY cd.month_labelORDER BY cd.month_label

    “This compiles correctly with the expected columns. Submitting.”

    Submitted · the SQL of the last dry run, unchanged · Submitted to tonight's batch. Your work on this request is done.

    “Submitted. The report groups 2025 carts for the six named retail tenants by calendar month (via calendar_days.month_label), with carts created, overall cart→order conversion %, share resolved within 1 hour, share resolved in 1–7 days, and the median days-to-convert for converted carts — ordered January through December 2025.”

  5. The report comes back overnight

    As Emeka Obi saw it, from the small dataset the agents worked on

    month   | carts_created | overall_conversion_pct | pct_resolved_within_1_hour | pct_resolved_1_7_days | median_days_to_convert
    --------+---------------+------------------------+----------------------------+-----------------------+-----------------------
    2025-01 |          1637 |                   37.9 |                       30.9 |                  68.5 |                    0.0
    2025-02 |          1136 |                   35.1 |                       26.5 |                  73.0 |                    0.0
    2025-03 |          1388 |                   35.4 |                       30.0 |                  69.5 |                    0.0
    2025-04 |          1367 |                   35.5 |                       28.6 |                  71.2 |                    0.0
    2025-05 |          1691 |                   35.7 |                       29.1 |                  70.4 |                    0.0
    2025-06 |          1337 |                   35.2 |                       29.8 |                  69.8 |                    0.0
    2025-07 |          1640 |                   34.3 |                       27.9 |                  71.3 |                    0.0
    2025-08 |          1345 |                   34.0 |                       28.0 |                  71.7 |                    0.0
    2025-09 |          1546 |                   35.8 |                       29.6 |                  69.9 |                    0.0
    2025-10 |          1919 |                   32.4 |                       28.6 |                  71.1 |                    0.0
    2025-11 |          2898 |                   37.7 |                       32.0 |                  67.5 |                    0.0
    2025-12 |          3293 |                   36.0 |                       30.0 |                  62.5 |                    0.0
    (12 rows)
  6. What came of it

    The memo and the queries that followed; links found by title, so inferred

    Later queries that build on this report:

    It builds on:

  7. It becomes benchmark query q288

    Changed only where the benchmark needs the same result on every system

    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision; bare NUMERIC cast that only served the rounding removed
    • numericsdocs/NUMERICS.md: EXTRACT(EPOCH FROM (b - a)) cast to NUMERIC(18,6)
    • placeholderpercentile cont
    • placeholderseconds between
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results
    The benchmark text, against the submitted SQL
    SELECT  cd.month_label AS month,  COUNT(*) AS carts_created,  ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*), 1) AS overall_conversion_pct,  ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*), 1) AS pct_resolved_within_1_hour,  ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*), 1) AS pct_resolved_1_7_days,  ROUND(CAST(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (c.converted_at - c.created_at))/86400.0) FILTER (WHERE c.status = 'converted') AS numeric), 1) AS median_days_to_convert  100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*) AS overall_conversion_pct,  100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*) AS pct_resolved_within_1_hour,  100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*) AS pct_resolved_1_7_days,  {{percentile_cont_filter(0.5, {{seconds_between(c.created_at, c.converted_at)}}/86400.0, c.status = 'converted')}} AS median_days_to_convertFROM carts cJOIN tenants t ON t.id = c.tenant_idJOIN calendar_days cd ON cd.calendar_date = CAST(c.created_at AS date)WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')  AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'GROUP BY cd.month_labelORDER BY cd.month_labelORDER BY cd.month_label NULLS LAST
    SELECT
      cd.month_label AS month,
      COUNT(*) AS carts_created,
      100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*) AS overall_conversion_pct,
      100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*) AS pct_resolved_within_1_hour,
      100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*) AS pct_resolved_1_7_days,
      PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY CAST(EXTRACT(EPOCH FROM (c.converted_at - c.created_at)) AS NUMERIC(18,6))/86400.0) FILTER (WHERE c.status = 'converted') AS median_days_to_convert
    FROM carts c
    JOIN tenants t ON t.id = c.tenant_id
    JOIN calendar_days cd ON cd.calendar_date = CAST(c.created_at AS date)
    WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
      AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
    GROUP BY cd.month_label
    ORDER BY cd.month_label NULLS LAST
    10 ms100 ms1 sHyper259 msDuckDB97.4 msGizmoSQL84.0 msclickhouse_brew468 msFirebolt162 msDataFusionwrong resultClickHouseerror
    • DataFusionrow 1: col 5: "68.9206325973346642415982416654269384150994" vs "69.50016556370844"
    • ClickHouseCode: 43. DB::Exception: No operation lessOrEquals between Decimal(18, 6) and IntervalHour: while executing 'FUNCTION lessOrEquals(minus(coalesce(__table1.converted_at, __table1.abandoned_at, __table1.expires_at), __table1.created_... : 1, _CAST(1_IntervalHour, 'IntervalHour'_String) : 7) -> lessOrE
  8. Everything, as recorded

    The agents' own records, as the role play wrote them; only the personal data of the person who ran it is redacted

    Viktor's whole session, 9 turns
    • The request, as Viktor received it
      Report request from Emeka Obi (analyst for the VP Product):
      
      Title: Cart bleed trend by month, 2025
      Question: Is the cart-stage drop-off getting better, worse, or staying flat over the past year?
      Grain: one row per calendar month of cart creation, 2025
      Columns:
      - month
      - carts created
      - overall cart-to-order conversion pct
      - pct resolved within 1 hour
      - pct resolved in 1-7 days
      - median days to convert (converted carts only)
      Details: Tenants: the 6 retail merchants (Kibblewick, Studio Vrij, Petalcourt Beauty, Marrowby & Vane, Cragsley Outfitters, Ampfeld Elektronik). Population: carts with created_at in 2025-01-01 to 2025-12-31, grouped by calendar month (use calendar_days or month_label-style grouping). overall_conversion_pct = share of carts in the month with status='converted', rounded 1 decimal. pct_resolved_within_1_hour and pct_resolved_1_7_days use the same resolution_time definition as the duration-bucket reports (coalesce(converted_at, abandoned_at, expires_at) - created_at), rounded 1 decimal. median_days_to_convert = median of (converted_at - created_at) in days, among carts with status='converted' in that month, rounded 1 decimal. Order rows by month ascending, January through December 2025.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Viktor calls dry_run
      SELECT
        to_char(date_trunc('month', c.created_at), 'YYYY-MM') AS month,
        COUNT(*) AS carts_created,
        ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*), 1) AS overall_conversion_pct,
        ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*), 1) AS pct_resolved_within_1_hour,
        ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*), 1) AS pct_resolved_1_7_days,
        ROUND(CAST(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (c.converted_at - c.created_at))/86400.0) FILTER (WHERE c.status = 'converted') AS numeric), 1) AS median_days_to_convert
      FROM carts c
      JOIN tenants t ON t.id = c.tenant_id
      WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
        AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
      GROUP BY date_trunc('month', c.created_at)
      ORDER BY date_trunc('month', c.created_at)
    • The warehouse answers
      STATUS: REJECTED
        - does not compile: Catalog Error: Scalar Function with name to_char does not exist!
    • Viktor calls dry_run
      SELECT
        cd.month_label AS month,
        COUNT(*) AS carts_created,
        ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*), 1) AS overall_conversion_pct,
        ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*), 1) AS pct_resolved_within_1_hour,
        ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*), 1) AS pct_resolved_1_7_days,
        ROUND(CAST(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (c.converted_at - c.created_at))/86400.0) FILTER (WHERE c.status = 'converted') AS numeric), 1) AS median_days_to_convert
      FROM carts c
      JOIN tenants t ON t.id = c.tenant_id
      JOIN calendar_days cd ON cd.calendar_date = CAST(c.created_at AS date)
      WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
        AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
      GROUP BY cd.month_label
      ORDER BY cd.month_label
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: month, carts_created, overall_conversion_pct, pct_resolved_within_1_hour, pct_resolved_1_7_days, median_days_to_convert
    • ViktorThis compiles correctly with the expected columns. Submitting.
    • Viktor calls submit
      SELECT
        cd.month_label AS month,
        COUNT(*) AS carts_created,
        ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'converted') / COUNT(*), 1) AS overall_conversion_pct,
        ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '1 hour') / COUNT(*), 1) AS pct_resolved_within_1_hour,
        ROUND(100.0 * COUNT(*) FILTER (WHERE COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at > INTERVAL '1 hour' AND COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at <= INTERVAL '7 days') / COUNT(*), 1) AS pct_resolved_1_7_days,
        ROUND(CAST(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (c.converted_at - c.created_at))/86400.0) FILTER (WHERE c.status = 'converted') AS numeric), 1) AS median_days_to_convert
      FROM carts c
      JOIN tenants t ON t.id = c.tenant_id
      JOIN calendar_days cd ON cd.calendar_date = CAST(c.created_at AS date)
      WHERE t.name IN ('Kibblewick','Studio Vrij','Petalcourt Beauty','Marrowby & Vane','Cragsley Outfitters','Ampfeld Elektronik')
        AND c.created_at >= DATE '2025-01-01' AND c.created_at < DATE '2026-01-01'
      GROUP BY cd.month_label
      ORDER BY cd.month_label
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • ViktorSubmitted. The report groups 2025 carts for the six named retail tenants by calendar month (via `calendar_days.month_label`), with carts created, overall cart→order conversion %, share resolved within 1 hour, share resolved in 1–7 days, and the median days-to-convert for converted carts — ordered January through December 2025.