Recipe Tableau and SQL

Which products are really bought together?

Flag every basket that holds your anchor product, count how many of those baskets hold each other product (confidence), then divide by that product's share of all baskets (lift). Lift above 1 means the pair sells together more than chance. The most common add-on is often a false lead: compost sits in 37% of tomato-plant baskets, but it is in 41% of all baskets, so its lift is 0.91.

Updated

The data

Fernhill Garden Centre till receipts, March to June 2025

Synthetic databaskets.csv

A garden centre's spring: 790 baskets across 26 products, from tomato plants and hanging baskets to bird seed and greeting cards. Each tomato plant scans as its own line.

order_idorder_dateproductcategoryquantity
T-202662025-03-01Slow-Release Plant FoodFeed2
T-202662025-03-01Gardening GlovesGarden Tools1
T-207042025-03-01Hanging Basket 14inPots & Baskets2
T-207042025-03-01Bedding Plants TrayPlants1

Step 2

Flag the baskets that hold the anchor

Save the Tableau formula as [Has anchor]. The FIXED expression stamps every line of a basket with 1 if any line in it is the anchor, so the next steps need no self-join. In a workbook, make the anchor a string parameter instead of typing the name.

Anchor share

16.6%

131 anchor baskets

Tableau

{ FIXED [order_id] : MAX(IF [product] = "{knob.anchor}" THEN 1 ELSE 0 END) }

SQL

WITH f AS (
  SELECT
    order_id,
    MAX(CASE WHEN product = '{knob.anchor}' THEN 1 ELSE 0 END) AS has_anchor
  FROM input
  GROUP BY order_id
)
SELECT
  SUM(has_anchor) AS anchor_baskets,
  AVG(has_anchor) AS anchor_share
FROM f

Rows

Has anchorHas anchor
13100%16.6%
Anchor product

Step 3

Of the anchor baskets, how many hold each product?

This is confidence, and it crowns compost: it is in more tomato baskets than anything else. Filter the anchor itself out (product <> anchor); its own confidence is always 100%.

Tableau

COUNTD(IF [Has anchor] = 1 THEN [order_id] END) / MIN({ FIXED : COUNTD(IF [Has anchor] = 1 THEN [order_id] END) })

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = '{knob.anchor}'
)
SELECT
  b.product,
  COUNT(*) AS with_anchor,
  COUNT(*) / (
    SELECT COUNT(*)
    FROM a
  ) AS confidence
FROM b JOIN a USING (order_id)
WHERE b.product <> '{knob.anchor}'
GROUP BY b.product
ORDER BY confidence DESC, b.product

Rows

ProductWith anchorConfidence
Peat-Free Compost 40L4937.4%
Bamboo Canes 10pk2922.1%
Tomato Feed 1L2418.3%
Herb Plant 9cm1914.5%
Garden Twine1612.2%
Houseplant Medium139.9%
Seed Packet139.9%
Greeting Card118.4%
Terracotta Pot 30cm118.4%
Gardening Gloves96.9%
Cafe Voucher86.1%
Slug Pellets75.3%
Slow-Release Plant Food43.1%
Watering Can 10L43.1%
Ceramic Planter32.3%
Basket Liner21.5%
Copper Slug Tape21.5%
Suet Balls 50pk21.5%
Garden Rake10.8%
Lawn Feed & Weed10.8%

Why isn't the most common add-on the best one?

Because it is in every basket anyway. Peat-Free Compost 40L is the product most often bought with Tomato Plant 9cm, but confidence alone can't tell a real pairing from a product everyone buys. For tomato plants, compost is in 37% of the baskets and in 41% of all baskets: a lift of 0.91, a touch below chance. Lift asks the better question: how much more often does this product turn up with the anchor than without it?

Step 4

Divide by how common each product is anyway

Lift is confidence over the product's share of all baskets. The reference line at 1 is chance. Compost drops below it; tomato feed, bamboo canes and twine rise to the top. [Confidence] is the previous step saved as a field. In Tableau, add a filter on COUNTD(IF [Has anchor] = 1 THEN [order_id] END) >= the minimum.

Tableau

[Confidence] / (COUNTD([order_id]) / MIN({ FIXED : COUNTD([order_id]) }))

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = '{knob.anchor}'
), s AS (
  SELECT
    product,
    COUNT(*)
      / (
      SELECT COUNT(DISTINCT order_id)
      FROM b
    ) AS basket_share
  FROM b
  GROUP BY product
), c AS (
  SELECT
    b.product,
    COUNT(*) AS with_anchor,
    COUNT(*)
      / (
      SELECT COUNT(*)
      FROM a
    ) AS confidence
  FROM b JOIN a USING (order_id)
  WHERE b.product <> '{knob.anchor}'
  GROUP BY b.product
)
SELECT
  c.product,
  c.with_anchor,
  c.confidence,
  c.confidence / s.basket_share AS lift
FROM c JOIN s USING (product)
WHERE c.with_anchor >= 10
ORDER BY lift DESC, c.product

Rows

ProductWith anchorConfidenceLift
Tomato Feed 1L240.184.14
Bamboo Canes 10pk290.223.18
Garden Twine160.122.76
Greeting Card110.080.91
Peat-Free Compost 40L490.370.91
Terracotta Pot 30cm110.080.85
Houseplant Medium130.10.83
Herb Plant 9cm190.150.82
Seed Packet130.10.56

With at least 10 shared baskets, the strongest partner for Tomato Plant 9cm is Tomato Feed 1L, at 4.1 times chance. Switch the anchor: hanging baskets pull in basket liners, bird feeders pull in wild bird seed, lawn seed pulls in lawn feed. Compost, the most common item in the shop, is the top partner for none of them.

Common mistake

Ranking add-ons by confidence

Confidence rewards products that are in every basket. Compost tops the tomato list at 37% only because it is in 41% of all baskets; its lift is 0.91, slightly below chance. Rank by lift, with a minimum count.

WrongStarts with Peat-Free Compost 40L, Bamboo Canes 10pk, Tomato Feed 1L…
Point at a bar to read it.
00.10.20.3Peat-Free Compost 40LBamboo Canes 10pkTomato Feed 1L

Tableau

COUNTD(IF [Has anchor] = 1 THEN [order_id] END) / MIN({ FIXED : COUNTD(IF [Has anchor] = 1 THEN [order_id] END) })
// sorted descending

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = 'Tomato Plant 9cm'
)
SELECT
  b.product,
  COUNT(*) / (
    SELECT COUNT(*)
    FROM a
  ) AS confidence
FROM b JOIN a USING (order_id)
WHERE b.product <> 'Tomato Plant 9cm'
GROUP BY b.product
ORDER BY confidence DESC, b.product
LIMIT 3
RightStarts with Tomato Feed 1L, Bamboo Canes 10pk, Garden Twine…
Point at a bar to read it.
024Tomato Feed 1LBamboo Canes 10pkGarden Twine

Tableau

[Confidence] / (COUNTD([order_id]) / MIN({ FIXED : COUNTD([order_id]) }))
// sorted descending, minimum 10 baskets

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = 'Tomato Plant 9cm'
), s AS (
  SELECT
    product,
    COUNT(*)
      / (
      SELECT COUNT(DISTINCT order_id)
      FROM b
    ) AS basket_share
  FROM b
  GROUP BY product
), c AS (
  SELECT
    b.product,
    COUNT(*) AS with_anchor,
    COUNT(*)
      / (
      SELECT COUNT(*)
      FROM a
    ) AS confidence
  FROM b JOIN a USING (order_id)
  WHERE b.product <> 'Tomato Plant 9cm'
  GROUP BY b.product
)
SELECT c.product, c.confidence / s.basket_share AS lift
FROM c JOIN s USING (product)
WHERE c.with_anchor >= 10
ORDER BY lift DESC, c.product
LIMIT 3

Step 5

Read every pairing in one table

Support says whether a pair is common enough to act on, confidence says how often the anchor brings the product along, lift says whether that is more than chance. A bundle needs all three.

Tableau

IF COUNTD(IF [Has anchor] = 1 THEN [order_id] END) < 10 THEN "Too few baskets"
ELSEIF [Lift] >= 1.5 THEN "Bought together"
ELSEIF [Lift] <= 0.67 THEN "Bought apart"
ELSE "Chance"
END

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = '{knob.anchor}'
), s AS (
  SELECT
    product,
    COUNT(*) AS baskets,
    COUNT(*)
      / (
      SELECT COUNT(DISTINCT order_id)
      FROM b
    ) AS basket_share
  FROM b
  GROUP BY product
), c AS (
  SELECT
    b.product,
    COUNT(*) AS with_anchor,
    COUNT(*)
      / (
      SELECT COUNT(*)
      FROM a
    ) AS confidence
  FROM b JOIN a USING (order_id)
  WHERE b.product <> '{knob.anchor}'
  GROUP BY b.product
)
SELECT
  c.product,
  s.baskets,
  c.with_anchor,
  c.with_anchor
    / (
    SELECT COUNT(DISTINCT order_id)
    FROM b
  ) AS support,
  c.confidence,
  c.confidence / s.basket_share AS lift,
  CASE
    WHEN c.with_anchor < 10 THEN 'Too few baskets'
    WHEN c.confidence / s.basket_share >= 1.5 THEN 'Bought together'
    WHEN c.confidence / s.basket_share <= 0.67 THEN 'Bought apart'
    ELSE 'Chance'
    END AS reading
FROM c JOIN s USING (product)
ORDER BY lift DESC, c.product

Rows

ProductBasketsWith anchorSupportConfidenceLiftReading
Copper Slug Tape220.3%1.5%6.03Too few baskets
Tomato Feed 1L35243%18.3%4.14Bought together
Bamboo Canes 10pk55293.7%22.1%3.18Bought together
Garden Twine35162%12.2%2.76Bought together
Gardening Gloves4491.1%6.9%1.23Too few baskets
Cafe Voucher4281%6.1%1.15Too few baskets
Slug Pellets4070.9%5.3%1.06Too few baskets
Greeting Card73111.4%8.4%0.91Chance
Peat-Free Compost 40L326496.2%37.4%0.91Chance
Terracotta Pot 30cm78111.4%8.4%0.85Chance
Houseplant Medium95131.6%9.9%0.83Chance
Herb Plant 9cm140192.4%14.5%0.82Chance
Watering Can 10L3040.5%3.1%0.8Too few baskets
Ceramic Planter2730.4%2.3%0.67Too few baskets
Seed Packet139131.6%9.9%0.56Bought apart
Garden Rake1410.1%0.8%0.43Too few baskets
Slow-Release Plant Food6040.5%3.1%0.4Too few baskets
Basket Liner3620.3%1.5%0.34Too few baskets
Suet Balls 50pk5220.3%1.5%0.23Too few baskets
Lawn Feed & Weed3110.1%0.8%0.19Too few baskets

How many baskets are enough?

Enough that one or two shoppers can't move the number. Two rare items bought together once get an enormous lift.

120
Point at the line to read it.
1Tomato Feed 1L4.142Bamboo Canes 10pk3.183Garden Twine2.764Greeting Card0.915Peat-Free Compost 40L0.916Terracotta Pot 30cm0.857Houseplant Medium0.838Herb Plant 9cm0.829Seed Packet0.56

Common mistake

Trusting a lift with no minimum count

Copper Slug Tape sold twice all spring, both times with tomatoes, so its lift is 6.0, higher than tomato feed at 4.1. Two baskets prove nothing. Drag the minimum down to 1 and watch it jump to the top.

WrongStarts with Copper Slug Tape…
Point at a bar to read it.
6.03Lift

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = 'Tomato Plant 9cm'
), s AS (
  SELECT
    product,
    COUNT(*)
      / (
      SELECT COUNT(DISTINCT order_id)
      FROM b
    ) AS basket_share
  FROM b
  GROUP BY product
), c AS (
  SELECT
    b.product,
    COUNT(*) AS with_anchor,
    COUNT(*)
      / (
      SELECT COUNT(*)
      FROM a
    ) AS confidence
  FROM b JOIN a USING (order_id)
  WHERE b.product <> 'Tomato Plant 9cm'
  GROUP BY b.product
)
SELECT
  c.product,
  c.with_anchor,
  c.confidence / s.basket_share AS lift
FROM c JOIN s USING (product)
ORDER BY lift DESC, c.product
LIMIT 1
RightStarts with Tomato Feed 1L…
Point at a bar to read it.
4.14Lift

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = 'Tomato Plant 9cm'
), s AS (
  SELECT
    product,
    COUNT(*)
      / (
      SELECT COUNT(DISTINCT order_id)
      FROM b
    ) AS basket_share
  FROM b
  GROUP BY product
), c AS (
  SELECT
    b.product,
    COUNT(*) AS with_anchor,
    COUNT(*)
      / (
      SELECT COUNT(*)
      FROM a
    ) AS confidence
  FROM b JOIN a USING (order_id)
  WHERE b.product <> 'Tomato Plant 9cm'
  GROUP BY b.product
)
SELECT
  c.product,
  c.with_anchor,
  c.confidence / s.basket_share AS lift
FROM c JOIN s USING (product)
WHERE c.with_anchor >= 10
ORDER BY lift DESC, c.product
LIMIT 1

Lines or baskets?

Baskets. Tills and order systems often write one line per unit or per scan, and a plain COUNT counts the same basket several times.

Common mistake

Counting lines instead of baskets

Each tomato plant scans as its own line, so a basket with four plants joins four times. Counted by lines, compost appears in 112 tomato baskets; there are only 49. Deduplicate to one row per basket and product first, or use COUNTD.

WrongStarts with Peat-Free Compost 40L…
Point at a bar to read it.
112With anchor

Tableau

COUNT(IF [Has anchor] = 1 THEN [order_id] END)

SQL

WITH a AS (
  SELECT order_id
  FROM input
  WHERE product = 'Tomato Plant 9cm'
)
SELECT i.product, COUNT(*) AS with_anchor
FROM input i JOIN a USING (order_id)
WHERE i.product = 'Peat-Free Compost 40L'
GROUP BY i.product
RightStarts with Peat-Free Compost 40L…
Point at a bar to read it.
49With anchor

Tableau

COUNTD(IF [Has anchor] = 1 THEN [order_id] END)

SQL

WITH b AS (
  SELECT DISTINCT order_id, product
  FROM input
), a AS (
  SELECT order_id
  FROM b
  WHERE product = 'Tomato Plant 9cm'
)
SELECT b.product, COUNT(*) AS with_anchor
FROM b JOIN a USING (order_id)
WHERE b.product = 'Peat-Free Compost 40L'
GROUP BY b.product

Run it on your own data

Any table with an order or basket id and a product column works. Drop your CSV, pick the two columns and an anchor; the steps above recompute in your browser, and nothing is uploaded. If every size and colour is its own SKU, roll them up to the parent product first, or the pairs split across dozens of rows.

Drop a CSV anywhere on this page

Up to 10,000 rows. The sample, baskets.csv, shows the shape.

Questions

What are support, confidence and lift?

Support is the share of all baskets that hold both products. Confidence is the share of anchor baskets that also hold the other product. Lift is confidence divided by the other product's share of all baskets: how much more often the pair meets than it would by chance.

What lift counts as strong?

Above 1.5 is worth a look, above 3 is a clear pairing, near 1 is chance, and below about 0.67 means the products tend to be bought apart, often because one replaces the other. Read lift only with enough baskets behind it.

Is lift symmetric?

Yes. The lift of tomato plants with tomato feed equals the lift of tomato feed with tomato plants. Confidence is not: most tomato-feed baskets hold a tomato plant, but most tomato-plant baskets don't hold feed.

What if the second product is bought a week later?

Basket analysis works per order. For cross-sell over time, flag customers instead of baskets: replace [order_id] with [customer_id] in the FIXED flag and count later purchases.

Is my data uploaded?

No. Your CSV is read and calculated in this browser tab.

Sources