DB Olympics

Queries · q295 · VP Product · week 2, night 3

Single-item vs. multi-item cart conversion, by tenant

“Does the pattern 'small, single-item carts convert worse than multi-item carts' hold consistently across all 6 retail merchants, or is it driven by one or two of them?”

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 SQLBeaWrites SQL the way the company's BI tool generates it: verbose fully qualified aliases (t0, t1, ...), a subquery per measure joined back on the grain keys, COALESCE everywhere.

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 3, request 3 of 3

    Reading last night's reports:

    Report 1 (Cragsley weekly conversion around the Nov 24 outage) undercuts the simple story: conversion didn't dip during the broken-reminder week — it hit 43.5%, the highest week in the whole window, up from a 29-41% range beforehand. The obvious explanation is that Nov 24-30, 2025 is Black Friday/Cyber Monday week — a massive demand spike that would swamp any negative effect from broken reminder emails. So this natural experiment is confounded by timing and can't cleanly tell us whether the reminders matter; I don't think it's worth forcing further at the weekly grain, but a daily cut around the exact outage start (before Black Friday really kicks in on Nov 28) might still show something, so I'll try that once.

    Report 2 (price tier vs. conversion) reinforces the Q1 story on a different cut: higher-priced carts (which are also higher-item-count: 1.3 items at <€50 vs 3.2 items at €200+) convert better and faster (51.4% / 44.1% within 1hr for €200+ vs 23.3% / 16.1% for <€50) — consistent with last night's finding that small, likely single-item, lower-value carts are where hesitation concentrates, not pricier "considered" purchases. Two independent cuts now agree, so I consider Q1 well-answered. Before I put "small single-item carts stall" in the memo as a general pattern, I want one quick robustness check that it's not just one merchant (e.g. Cragsley, which dominates volume) driving the aggregate.

    Report 3 (email nudge) shows a striking gap — 56.0% eventual conversion for carts whose user got an email vs. 8.1% for no-email — but it's likely confounded: 7,540 of the 12,280 "no_email" carts are guest carts that structurally can never receive an email (no user_id to target), and guests plausibly convert worse regardless of email. I want to split "no_email" into guest vs. logged-in before trusting this as evidence the nudge itself works, since the fair comparison is received_email vs. logged-in-but-not-emailed.

    Tonight's 3 requests: (1) the cleaner 3-way email-nudge split, (2) a daily drill-down at Cragsley right at the outage start to see if Black Friday is really masking a real dip, (3) a per-tenant robustness check that the single-item-cart-stalls pattern isn't just one merchant's effect.

    Single-item vs. multi-item cart conversion, by tenant
    Question
    Does the pattern 'small, single-item carts convert worse than multi-item carts' hold consistently across all 6 retail merchants, or is it driven by one or two of them?
    One row per
    tenant
    Columns
    • tenant name
    • single-item cart count
    • single-item conversion pct
    • multi-item (2+) cart count
    • multi-item conversion pct
    • gap in points (multi minus single)
  4. Bea 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)
    SELECT
      t0.name AS tenant_name,
      COALESCE(t1.single_count, 0) AS single_item_cart_count,
      COALESCE(t2.single_conv_pct, 0) AS single_item_conversion_pct,
      COALESCE(t3.multi_count, 0) AS multi_item_cart_count,
      COALESCE(t4.multi_conv_pct, 0) AS multi_item_conversion_pct,
      COALESCE(t4.multi_conv_pct, 0) - COALESCE(t2.single_conv_pct, 0) AS gap_in_points
    FROM tenants t0
    LEFT JOIN (
      SELECT c0.tenant_id AS tenant_id, COUNT(*) AS single_count
      FROM carts c0
      WHERE c0.item_count = 1
        AND c0.created_at >= DATE '2025-01-01'
        AND c0.created_at < DATE '2026-01-01'
      GROUP BY c0.tenant_id
    ) t1 ON t1.tenant_id = t0.id
    LEFT JOIN (
      SELECT c1.tenant_id AS tenant_id,
        ROUND(100.0 * SUM(CASE WHEN c1.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS single_conv_pct
      FROM carts c1
      WHERE c1.item_count = 1
        AND c1.created_at >= DATE '2025-01-01'
        AND c1.created_at < DATE '2026-01-01'
      GROUP BY c1.tenant_id
    ) t2 ON t2.tenant_id = t0.id
    LEFT JOIN (
      SELECT c2.tenant_id AS tenant_id, COUNT(*) AS multi_count
      FROM carts c2
      WHERE c2.item_count >= 2
        AND c2.created_at >= DATE '2025-01-01'
        AND c2.created_at < DATE '2026-01-01'
      GROUP BY c2.tenant_id
    ) t3 ON t3.tenant_id = t0.id
    LEFT JOIN (
      SELECT c3.tenant_id AS tenant_id,
        ROUND(100.0 * SUM(CASE WHEN c3.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS multi_conv_pct
      FROM carts c3
      WHERE c3.item_count >= 2
        AND c3.created_at >= DATE '2025-01-01'
        AND c3.created_at < DATE '2026-01-01'
      GROUP BY c3.tenant_id
    ) t4 ON t4.tenant_id = t0.id
    WHERE t0.name IN ('Kibblewick', 'Studio Vrij', 'Petalcourt Beauty', 'Marrowby & Vane', 'Cragsley Outfitters', 'Ampfeld Elektronik')
    ORDER BY gap_in_points DESC, tenant_name ASC

    “Query compiles and returns 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 returns one row per tenant (the six named retail merchants) with single-item and multi-item cart counts, their conversion percentages, and the gap in points, ordered by gap descending — exactly as requested.”

  5. The report comes back overnight

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

    tenant_name         | single_item_cart_count | single_item_conversion_pct | multi_item_cart_count | multi_item_conversion_pct |      gap_in_points
    --------------------+------------------------+----------------------------+-----------------------+---------------------------+-------------------
    Cragsley Outfitters |                   5393 |                       19.5 |                  4791 |                      53.8 |               34.3
    Marrowby & Vane     |                   2686 |                       21.6 |                  2141 |                      54.7 |               33.1
    Ampfeld Elektronik  |                   1009 |                       17.1 |                   542 |                      50.0 |               32.9
    Studio Vrij         |                   1079 |                       19.6 |                   820 |                      50.7 |               31.1
    Petalcourt Beauty   |                    882 |                       27.6 |                   831 |                      55.4 | 27.799999999999997
    Kibblewick          |                    542 |                       30.3 |                   481 |                      46.4 | 16.099999999999998
    (6 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 q295

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

    • determinismtie-breaker: tenant names are not unique
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results (3×)
    The benchmark text, against the submitted SQL
    SELECT  t0.name AS tenant_name,  COALESCE(t1.single_count, 0) AS single_item_cart_count,  COALESCE(t2.single_conv_pct, 0) AS single_item_conversion_pct,  COALESCE(t3.multi_count, 0) AS multi_item_cart_count,  COALESCE(t4.multi_conv_pct, 0) AS multi_item_conversion_pct,  COALESCE(t4.multi_conv_pct, 0) - COALESCE(t2.single_conv_pct, 0) AS gap_in_pointsFROM tenants t0LEFT JOIN (  SELECT c0.tenant_id AS tenant_id, COUNT(*) AS single_count  FROM carts c0  WHERE c0.item_count = 1    AND c0.created_at >= DATE '2025-01-01'    AND c0.created_at < DATE '2026-01-01'  GROUP BY c0.tenant_id) t1 ON t1.tenant_id = t0.idLEFT JOIN (  SELECT c1.tenant_id AS tenant_id,    ROUND(100.0 * SUM(CASE WHEN c1.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS single_conv_pct    100.0 * SUM(CASE WHEN c1.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*) AS single_conv_pct  FROM carts c1  WHERE c1.item_count = 1    AND c1.created_at >= DATE '2025-01-01'    AND c1.created_at < DATE '2026-01-01'  GROUP BY c1.tenant_id) t2 ON t2.tenant_id = t0.idLEFT JOIN (  SELECT c2.tenant_id AS tenant_id, COUNT(*) AS multi_count  FROM carts c2  WHERE c2.item_count >= 2    AND c2.created_at >= DATE '2025-01-01'    AND c2.created_at < DATE '2026-01-01'  GROUP BY c2.tenant_id) t3 ON t3.tenant_id = t0.idLEFT JOIN (  SELECT c3.tenant_id AS tenant_id,    ROUND(100.0 * SUM(CASE WHEN c3.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS multi_conv_pct    100.0 * SUM(CASE WHEN c3.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*) AS multi_conv_pct  FROM carts c3  WHERE c3.item_count >= 2    AND c3.created_at >= DATE '2025-01-01'    AND c3.created_at < DATE '2026-01-01'  GROUP BY c3.tenant_id) t4 ON t4.tenant_id = t0.idWHERE t0.name IN ('Kibblewick', 'Studio Vrij', 'Petalcourt Beauty', 'Marrowby & Vane', 'Cragsley Outfitters', 'Ampfeld Elektronik')ORDER BY gap_in_points DESC, tenant_name ASCORDER BY gap_in_points DESC NULLS FIRST, tenant_name ASC NULLS LAST, t0.id ASC NULLS LAST
    SELECT
      t0.name AS tenant_name,
      COALESCE(t1.single_count, 0) AS single_item_cart_count,
      COALESCE(t2.single_conv_pct, 0) AS single_item_conversion_pct,
      COALESCE(t3.multi_count, 0) AS multi_item_cart_count,
      COALESCE(t4.multi_conv_pct, 0) AS multi_item_conversion_pct,
      COALESCE(t4.multi_conv_pct, 0) - COALESCE(t2.single_conv_pct, 0) AS gap_in_points
    FROM tenants t0
    LEFT JOIN (
      SELECT c0.tenant_id AS tenant_id, COUNT(*) AS single_count
      FROM carts c0
      WHERE c0.item_count = 1
        AND c0.created_at >= DATE '2025-01-01'
        AND c0.created_at < DATE '2026-01-01'
      GROUP BY c0.tenant_id
    ) t1 ON t1.tenant_id = t0.id
    LEFT JOIN (
      SELECT c1.tenant_id AS tenant_id,
        100.0 * SUM(CASE WHEN c1.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*) AS single_conv_pct
      FROM carts c1
      WHERE c1.item_count = 1
        AND c1.created_at >= DATE '2025-01-01'
        AND c1.created_at < DATE '2026-01-01'
      GROUP BY c1.tenant_id
    ) t2 ON t2.tenant_id = t0.id
    LEFT JOIN (
      SELECT c2.tenant_id AS tenant_id, COUNT(*) AS multi_count
      FROM carts c2
      WHERE c2.item_count >= 2
        AND c2.created_at >= DATE '2025-01-01'
        AND c2.created_at < DATE '2026-01-01'
      GROUP BY c2.tenant_id
    ) t3 ON t3.tenant_id = t0.id
    LEFT JOIN (
      SELECT c3.tenant_id AS tenant_id,
        100.0 * SUM(CASE WHEN c3.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*) AS multi_conv_pct
      FROM carts c3
      WHERE c3.item_count >= 2
        AND c3.created_at >= DATE '2025-01-01'
        AND c3.created_at < DATE '2026-01-01'
      GROUP BY c3.tenant_id
    ) t4 ON t4.tenant_id = t0.id
    WHERE t0.name IN ('Kibblewick', 'Studio Vrij', 'Petalcourt Beauty', 'Marrowby & Vane', 'Cragsley Outfitters', 'Ampfeld Elektronik')
    ORDER BY gap_in_points DESC NULLS FIRST, tenant_name ASC NULLS LAST, t0.id ASC NULLS LAST
    10 ms100 ms1 sHyper19.6 msDuckDB22.4 msGizmoSQL33.9 msclickhouse_brew247 msFirebolt106 msDataFusion197 msClickHouse163 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

    Bea's whole session, 7 turns
    • The request, as Bea received it
      Report request from Emeka Obi (analyst for the VP Product):
      
      Title: Single-item vs. multi-item cart conversion, by tenant
      Question: Does the pattern 'small, single-item carts convert worse than multi-item carts' hold consistently across all 6 retail merchants, or is it driven by one or two of them?
      Grain: one row per tenant
      Columns:
      - tenant name
      - single-item cart count
      - single-item conversion pct
      - multi-item (2+) cart count
      - multi-item conversion pct
      - gap in points (multi minus single)
      Details: Tenants: Kibblewick, Studio Vrij, Petalcourt Beauty, Marrowby & Vane, Cragsley Outfitters, Ampfeld Elektronik. Population: carts with created_at in 2025-01-01 to 2025-12-31. Split each tenant's carts into item_count = 1 ('single-item') vs item_count >= 2 ('multi-item') using carts.item_count. conversion pct = share of carts in that group with status='converted', rounded 1 decimal, for each tenant. gap_in_points = multi-item conversion pct minus single-item conversion pct. Order rows by gap in points descending, tie-break by tenant name.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Bea calls dry_run
      SELECT
        t0.name AS tenant_name,
        COALESCE(t1.single_count, 0) AS single_item_cart_count,
        COALESCE(t2.single_conv_pct, 0) AS single_item_conversion_pct,
        COALESCE(t3.multi_count, 0) AS multi_item_cart_count,
        COALESCE(t4.multi_conv_pct, 0) AS multi_item_conversion_pct,
        COALESCE(t4.multi_conv_pct, 0) - COALESCE(t2.single_conv_pct, 0) AS gap_in_points
      FROM tenants t0
      LEFT JOIN (
        SELECT c0.tenant_id AS tenant_id, COUNT(*) AS single_count
        FROM carts c0
        WHERE c0.item_count = 1
          AND c0.created_at >= DATE '2025-01-01'
          AND c0.created_at < DATE '2026-01-01'
        GROUP BY c0.tenant_id
      ) t1 ON t1.tenant_id = t0.id
      LEFT JOIN (
        SELECT c1.tenant_id AS tenant_id,
          ROUND(100.0 * SUM(CASE WHEN c1.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS single_conv_pct
        FROM carts c1
        WHERE c1.item_count = 1
          AND c1.created_at >= DATE '2025-01-01'
          AND c1.created_at < DATE '2026-01-01'
        GROUP BY c1.tenant_id
      ) t2 ON t2.tenant_id = t0.id
      LEFT JOIN (
        SELECT c2.tenant_id AS tenant_id, COUNT(*) AS multi_count
        FROM carts c2
        WHERE c2.item_count >= 2
          AND c2.created_at >= DATE '2025-01-01'
          AND c2.created_at < DATE '2026-01-01'
        GROUP BY c2.tenant_id
      ) t3 ON t3.tenant_id = t0.id
      LEFT JOIN (
        SELECT c3.tenant_id AS tenant_id,
          ROUND(100.0 * SUM(CASE WHEN c3.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS multi_conv_pct
        FROM carts c3
        WHERE c3.item_count >= 2
          AND c3.created_at >= DATE '2025-01-01'
          AND c3.created_at < DATE '2026-01-01'
        GROUP BY c3.tenant_id
      ) t4 ON t4.tenant_id = t0.id
      WHERE t0.name IN ('Kibblewick', 'Studio Vrij', 'Petalcourt Beauty', 'Marrowby & Vane', 'Cragsley Outfitters', 'Ampfeld Elektronik')
      ORDER BY gap_in_points DESC, tenant_name ASC
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: tenant_name, single_item_cart_count, single_item_conversion_pct, multi_item_cart_count, multi_item_conversion_pct, gap_in_points
    • BeaQuery compiles and returns the expected columns. Submitting.
    • Bea calls submit
      SELECT
        t0.name AS tenant_name,
        COALESCE(t1.single_count, 0) AS single_item_cart_count,
        COALESCE(t2.single_conv_pct, 0) AS single_item_conversion_pct,
        COALESCE(t3.multi_count, 0) AS multi_item_cart_count,
        COALESCE(t4.multi_conv_pct, 0) AS multi_item_conversion_pct,
        COALESCE(t4.multi_conv_pct, 0) - COALESCE(t2.single_conv_pct, 0) AS gap_in_points
      FROM tenants t0
      LEFT JOIN (
        SELECT c0.tenant_id AS tenant_id, COUNT(*) AS single_count
        FROM carts c0
        WHERE c0.item_count = 1
          AND c0.created_at >= DATE '2025-01-01'
          AND c0.created_at < DATE '2026-01-01'
        GROUP BY c0.tenant_id
      ) t1 ON t1.tenant_id = t0.id
      LEFT JOIN (
        SELECT c1.tenant_id AS tenant_id,
          ROUND(100.0 * SUM(CASE WHEN c1.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS single_conv_pct
        FROM carts c1
        WHERE c1.item_count = 1
          AND c1.created_at >= DATE '2025-01-01'
          AND c1.created_at < DATE '2026-01-01'
        GROUP BY c1.tenant_id
      ) t2 ON t2.tenant_id = t0.id
      LEFT JOIN (
        SELECT c2.tenant_id AS tenant_id, COUNT(*) AS multi_count
        FROM carts c2
        WHERE c2.item_count >= 2
          AND c2.created_at >= DATE '2025-01-01'
          AND c2.created_at < DATE '2026-01-01'
        GROUP BY c2.tenant_id
      ) t3 ON t3.tenant_id = t0.id
      LEFT JOIN (
        SELECT c3.tenant_id AS tenant_id,
          ROUND(100.0 * SUM(CASE WHEN c3.status = 'converted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS multi_conv_pct
        FROM carts c3
        WHERE c3.item_count >= 2
          AND c3.created_at >= DATE '2025-01-01'
          AND c3.created_at < DATE '2026-01-01'
        GROUP BY c3.tenant_id
      ) t4 ON t4.tenant_id = t0.id
      WHERE t0.name IN ('Kibblewick', 'Studio Vrij', 'Petalcourt Beauty', 'Marrowby & Vane', 'Cragsley Outfitters', 'Ampfeld Elektronik')
      ORDER BY gap_in_points DESC, tenant_name ASC
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • BeaSubmitted. The report returns one row per tenant (the six named retail merchants) with single-item and multi-item cart counts, their conversion percentages, and the gap in points, ordered by gap descending — exactly as requested.