DB Olympics

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

Cart abandonment: cliff or slow bleed, Q4 2025 (non-subscription merchants)

“When a cart doesn't convert, does the shopper give up quickly within the same visit, or does the cart linger for days before being abandoned or expiring?”

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 SQLInesThinks in window functions: ranks, running totals, LAG/LEAD for comparisons, percent-of-total.

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 1

    Subject: Shopper journey end-to-end — what I need for the 2026 roadmap

    Emeka,

    We're resetting the shopper journey for 2026, and I need a clear picture of the whole path — first visit through checkout, delivery, returns, and whether people come back — so we can decide where to invest design and engineering time first. This feeds the roadmap review I'm presenting in two weeks, so the Friday memo should leave me able to say "here's where shoppers are leaking out, and here's the one or two places a fix would move the most volume."

    I know you only get a handful of report slots this week, so please prioritize in the order below — stop earlier in the list if something forces a tradeoff, rather than spreading thin across all of it.

    1. Where do we lose people between landing on the storefront and placing an order? I want to see the shape of the funnel (visit → browse/search → cart → checkout → paid order) and which step is the biggest drop-off, ideally for a few of our bigger merchants so we're not averaging away real differences.
    2. Once someone reaches checkout, what's stopping them from finishing? Is it a one-step cliff or a slow bleed across the checkout flow, and does it differ by device or by how they arrived (e.g. ad vs. direct vs. returning)?
    3. After the order is placed, how is delivery experience affecting trust? I want to understand how often deliveries run late and whether that visibly connects to returns or to people not coming back.
    4. How painful are returns, and does a return kill the relationship? I'd like a sense of how often a return leads to the shopper buying again within a reasonable window versus going quiet.
    5. Who comes back, and how soon? A simple read on repeat purchase behavior — how many first-time buyers return, and roughly how long it takes — would help me know if we're building a roadmap for acquisition or for retention.

    If you can only land three of these this week, do 1, 2 and 5 — funnel, checkout, and repeat purchase are the ones I most need going into the roadmap conversation. Delivery and returns can follow next week if we run out of nights.

    Thanks — looking forward to Friday.

    Jonas

  3. Emeka Obi plans the night

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

    Reading the three reports critically:

    Report 1 (funnel): several tenants show visit→cart conversion ABOVE 100% (up to 191.5%), which is impossible for a real per-visitor funnel. The engineer's note explains why: stages are counted independently with no row-level join, so this isn't "% of visitors who added to cart" — it's just cart_count/visit_count. The merchants with the worst over-100% ratios (Brennquell Kaffee, Spice Route Box, Grind Collective, Honeycomb Larder) read like subscription/recurring-box businesses, while the merchants with ratios close to 100-150% and a big cart→order drop (Kibblewick, Studio Vrij, Petalcourt Beauty, Marrowby & Vane, Cragsley Outfitters, Ampfeld Elektronik) read like regular one-off retail. My hypothesis: subscription merchants generate many carts/orders from recurring billing that never involve a fresh tracked web session, which would make "visits" structurally incomparable across merchant types. I want to test that before telling Jonas where the "biggest leak" is, otherwise I'd be mixing a tracking-coverage artifact with real shopper drop-off.

    For the real retail merchants, the consistent, trustworthy signal across stages is: cart→order is the dominant drop (27-61% conversion) while order→paid is consistently high (94-99%). That's the leak worth roadmapping — but I want one more clean (schema-safe) cut at where in that drop it happens, since Report 2's device/channel attempt failed.

    Report 2 (checkout by device/channel): 16,987 of ~17,158 carts (99%) landed in device=unknown/channel=unknown — the engineer flagged exactly this risk. This means the user_id-based same-day join to web_sessions essentially didn't work (most carts are likely guest/anonymous, with no user_id to match on). I'm treating device/channel-by-cart as not reliably answerable with the current schema and pivoting to schema-clean alternatives (order_status_history for stall location; cart timestamps for cliff-vs-bleed shape) rather than re-trying the same fragile join.

    Report 3 (repeat purchase): looks internally consistent — no red flags. Repeat-within-90-days sits in a 40-62% band, median days-to-second-order 32-78 days, with the two most recent cohorts showing lower medians as expected from a shorter observation window. I consider priority 5 answered; no follow-up planned tonight.

    Tonight's 3 requests: (A) test the subscription-vs-session-tracking-gap hypothesis from Report 1, (B) find exactly which status the post-checkout order stalls at using the clean orders/order_status_history join (no fragile session join needed), (C) check whether cart abandonment is a quick cliff or a slow lingering bleed using carts' own timestamps, restricted to the real-retail tenants so we're not mixing in the subscription pattern.

    Cart abandonment: cliff or slow bleed, Q4 2025 (non-subscription merchants)
    Question
    When a cart doesn't convert, does the shopper give up quickly within the same visit, or does the cart linger for days before being abandoned or expiring?
    One row per
    time-to-resolution bucket
    Columns
    • duration bucket
    • cart count
    • pct of carts
    • converted pct (share of carts in this bucket that ended status = converted)
  4. Ines writes the SQL

    The engineer · 1 dry run on the empty schema, then the submission

    Dry run 1 · STATUS: COMPILES (dry run on the empty schema, no data read)
    WITH base AS (
      SELECT
        c.id,
        c.status,
        c.created_at,
        c.converted_at,
        c.abandoned_at,
        c.expires_at,
        CASE
          WHEN c.converted_at IS NOT NULL OR c.abandoned_at IS NOT NULL OR c.expires_at IS NOT NULL
          THEN EXTRACT(EPOCH FROM (COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at)) / 3600.0
          ELSE NULL
        END AS resolution_hours
      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-10-01'
        AND c.created_at < DATE '2026-01-01'
    ),
    bucketed AS (
      SELECT
        *,
        CASE
          WHEN resolution_hours IS NULL THEN 'unresolved/still active'
          WHEN resolution_hours < 1 THEN '<1 hour'
          WHEN resolution_hours < 24 THEN '1-24 hours'
          WHEN resolution_hours < 168 THEN '1-7 days'
          ELSE '>7 days'
        END AS duration_bucket
      FROM base
    )
    SELECT
      duration_bucket,
      COUNT(*) AS cart_count,
      ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pct_of_carts,
      ROUND(SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS converted_pct
    FROM bucketed
    GROUP BY duration_bucket
    ORDER BY
      CASE duration_bucket
        WHEN '<1 hour' THEN 1
        WHEN '1-24 hours' THEN 2
        WHEN '1-7 days' THEN 3
        WHEN '>7 days' THEN 4
        WHEN 'unresolved/still active' THEN 5
      END

    “Query compiles correctly. 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 buckets carts from the six named tenants (Q4 2025 creation window) into <1 hour, 1-24 hours, 1-7 days, >7 days, and unresolved/still active based on time from creation to the earliest of converted_at/abandoned_at/expires_at, with cart counts, share of total, and converted-rate per bucket, ordered in the requested fixed sequence.”

  5. The report comes back overnight

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

    duration_bucket | cart_count | pct_of_carts | converted_pct
    ----------------+------------+--------------+--------------
    <1 hour         |       2350 |         29.0 |         100.0
    1-24 hours      |       2455 |         30.3 |           1.8
    1-7 days        |       3034 |         37.4 |          15.3
    >7 days         |        271 |          3.3 |          15.5
    (4 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 q081

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

    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: EXTRACT(EPOCH FROM (b - a)) cast to NUMERIC(18,6)
    • made explicita selected t.col gets AS col, its standard name made explicit (6×)
    • 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
    WITH base AS (  SELECT    c.id,    c.status,    c.created_at,    c.converted_at,    c.abandoned_at,    c.expires_at,    c.id AS id,    c.status AS status,    c.created_at AS created_at,    c.converted_at AS converted_at,    c.abandoned_at AS abandoned_at,    c.expires_at AS expires_at,    CASE      WHEN c.converted_at IS NOT NULL OR c.abandoned_at IS NOT NULL OR c.expires_at IS NOT NULL      THEN EXTRACT(EPOCH FROM (COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at)) / 3600.0      THEN {{seconds_between(c.created_at, COALESCE(c.converted_at, c.abandoned_at, c.expires_at))}} / 3600.0      ELSE NULL    END AS resolution_hours  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-10-01'    AND c.created_at < DATE '2026-01-01'),bucketed AS (  SELECT    *,    CASE      WHEN resolution_hours IS NULL THEN 'unresolved/still active'      WHEN resolution_hours < 1 THEN '<1 hour'      WHEN resolution_hours < 24 THEN '1-24 hours'      WHEN resolution_hours < 168 THEN '1-7 days'      ELSE '>7 days'    END AS duration_bucket  FROM base)SELECT  duration_bucket,  COUNT(*) AS cart_count,  ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pct_of_carts,  ROUND(SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS converted_pct  COUNT(*) * 100.0 / SUM(COUNT(*)) OVER () AS pct_of_carts,  SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS converted_pctFROM bucketedGROUP BY duration_bucketORDER BY  CASE duration_bucket    WHEN '<1 hour' THEN 1    WHEN '1-24 hours' THEN 2    WHEN '1-7 days' THEN 3    WHEN '>7 days' THEN 4    WHEN 'unresolved/still active' THEN 5  END  END NULLS LAST
    WITH base AS (
      SELECT
        c.id AS id,
        c.status AS status,
        c.created_at AS created_at,
        c.converted_at AS converted_at,
        c.abandoned_at AS abandoned_at,
        c.expires_at AS expires_at,
        CASE
          WHEN c.converted_at IS NOT NULL OR c.abandoned_at IS NOT NULL OR c.expires_at IS NOT NULL
          THEN CAST(EXTRACT(EPOCH FROM (COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at)) AS NUMERIC(18,6)) / 3600.0
          ELSE NULL
        END AS resolution_hours
      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-10-01'
        AND c.created_at < DATE '2026-01-01'
    ),
    bucketed AS (
      SELECT
        *,
        CASE
          WHEN resolution_hours IS NULL THEN 'unresolved/still active'
          WHEN resolution_hours < 1 THEN '<1 hour'
          WHEN resolution_hours < 24 THEN '1-24 hours'
          WHEN resolution_hours < 168 THEN '1-7 days'
          ELSE '>7 days'
        END AS duration_bucket
      FROM base
    )
    SELECT
      duration_bucket,
      COUNT(*) AS cart_count,
      COUNT(*) * 100.0 / SUM(COUNT(*)) OVER () AS pct_of_carts,
      SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS converted_pct
    FROM bucketed
    GROUP BY duration_bucket
    ORDER BY
      CASE duration_bucket
        WHEN '<1 hour' THEN 1
        WHEN '1-24 hours' THEN 2
        WHEN '1-7 days' THEN 3
        WHEN '>7 days' THEN 4
        WHEN 'unresolved/still active' THEN 5
      END NULLS LAST
    10 ms100 ms1 sHyper14.5 msDuckDB23.6 msGizmoSQL33.2 msclickhouse_brew126 msFirebolt67.7 msDataFusion141 msClickHouse219 ms
  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

    Ines's whole session, 7 turns
    • The request, as Ines received it
      Report request from Emeka Obi (analyst for the VP Product):
      
      Title: Cart abandonment: cliff or slow bleed, Q4 2025 (non-subscription merchants)
      Question: When a cart doesn't convert, does the shopper give up quickly within the same visit, or does the cart linger for days before being abandoned or expiring?
      Grain: one row per time-to-resolution bucket
      Columns:
      - duration bucket
      - cart count
      - pct of carts
      - converted pct (share of carts in this bucket that ended status = converted)
      Details: Base population: carts with created_at in 2025-10-01 to 2025-12-31, restricted to tenant_id for Kibblewick, Studio Vrij, Petalcourt Beauty, Marrowby & Vane, Cragsley Outfitters, Ampfeld Elektronik (the non-subscription-looking merchants from the Q4 funnel report; look up tenant_id by name in tenants). For each cart compute resolution_time = coalesce(converted_at, abandoned_at, expires_at) minus created_at, in hours; carts where all three are null are 'unresolved/still active' (no time computed). Bucket resolution_time into: '<1 hour', '1-24 hours', '1-7 days', '>7 days', plus a separate 'unresolved/still active' bucket for carts with no resolution timestamp. cart_count = carts in the bucket; pct_of_carts = bucket count / total carts in the base population * 100 (1 decimal); converted_pct = % of carts in that bucket with status = 'converted' (1 decimal). Order rows in this fixed sequence: '<1 hour', '1-24 hours', '1-7 days', '>7 days', 'unresolved/still active' (not alphabetical).
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Ines calls dry_run
      WITH base AS (
        SELECT
          c.id,
          c.status,
          c.created_at,
          c.converted_at,
          c.abandoned_at,
          c.expires_at,
          CASE
            WHEN c.converted_at IS NOT NULL OR c.abandoned_at IS NOT NULL OR c.expires_at IS NOT NULL
            THEN EXTRACT(EPOCH FROM (COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at)) / 3600.0
            ELSE NULL
          END AS resolution_hours
        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-10-01'
          AND c.created_at < DATE '2026-01-01'
      ),
      bucketed AS (
        SELECT
          *,
          CASE
            WHEN resolution_hours IS NULL THEN 'unresolved/still active'
            WHEN resolution_hours < 1 THEN '<1 hour'
            WHEN resolution_hours < 24 THEN '1-24 hours'
            WHEN resolution_hours < 168 THEN '1-7 days'
            ELSE '>7 days'
          END AS duration_bucket
        FROM base
      )
      SELECT
        duration_bucket,
        COUNT(*) AS cart_count,
        ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pct_of_carts,
        ROUND(SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS converted_pct
      FROM bucketed
      GROUP BY duration_bucket
      ORDER BY
        CASE duration_bucket
          WHEN '<1 hour' THEN 1
          WHEN '1-24 hours' THEN 2
          WHEN '1-7 days' THEN 3
          WHEN '>7 days' THEN 4
          WHEN 'unresolved/still active' THEN 5
        END
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: duration_bucket, cart_count, pct_of_carts, converted_pct
    • InesQuery compiles correctly. Submitting.
    • Ines calls submit
      WITH base AS (
        SELECT
          c.id,
          c.status,
          c.created_at,
          c.converted_at,
          c.abandoned_at,
          c.expires_at,
          CASE
            WHEN c.converted_at IS NOT NULL OR c.abandoned_at IS NOT NULL OR c.expires_at IS NOT NULL
            THEN EXTRACT(EPOCH FROM (COALESCE(c.converted_at, c.abandoned_at, c.expires_at) - c.created_at)) / 3600.0
            ELSE NULL
          END AS resolution_hours
        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-10-01'
          AND c.created_at < DATE '2026-01-01'
      ),
      bucketed AS (
        SELECT
          *,
          CASE
            WHEN resolution_hours IS NULL THEN 'unresolved/still active'
            WHEN resolution_hours < 1 THEN '<1 hour'
            WHEN resolution_hours < 24 THEN '1-24 hours'
            WHEN resolution_hours < 168 THEN '1-7 days'
            ELSE '>7 days'
          END AS duration_bucket
        FROM base
      )
      SELECT
        duration_bucket,
        COUNT(*) AS cart_count,
        ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS pct_of_carts,
        ROUND(SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS converted_pct
      FROM bucketed
      GROUP BY duration_bucket
      ORDER BY
        CASE duration_bucket
          WHEN '<1 hour' THEN 1
          WHEN '1-24 hours' THEN 2
          WHEN '1-7 days' THEN 3
          WHEN '>7 days' THEN 4
          WHEN 'unresolved/still active' THEN 5
        END
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • InesSubmitted. The report buckets carts from the six named tenants (Q4 2025 creation window) into `<1 hour`, `1-24 hours`, `1-7 days`, `>7 days`, and `unresolved/still active` based on time from creation to the earliest of converted_at/abandoned_at/expires_at, with cart counts, share of total, and converted-rate per bucket, ordered in the requested fixed sequence.