Recipe Tableau and SQL
Which customers are my best, and which am I about to lose?
Give each customer three numbers: days since their last order, number of orders, and total spend. Score each 1 to 5, then name segments from the scores. At this yarn shop, 30 of the 76 biggest spenders haven't ordered in over 180 days: a ranking by spend alone would call them your best customers.
The data
Purl & Pine yarn shop, online orders January 2025 to September 2026
An online yarn shop with 380 customers. A group of big-spending knitting-club regulars bought heavily through 2025, then went quiet.
order_id | order_date | customer_id | customer | revenue |
|---|---|---|---|---|
| PP-30001 | 2025-01-01 | C0096 | Moira Berg | 97.52 |
| PP-30002 | 2025-01-01 | C0099 | Yara Tanaka | 93.85 |
| PP-30003 | 2025-01-01 | C0323 | Mira Young | 64.26 |
| PP-30004 | 2025-01-02 | C0044 | Ava Patel | 47.7 |
Step 1
Three numbers per customer
One dot per customer. Recency is counted from the last order in the file, not from today. The cluster at the top right is the surprise: customers who spent the most and then stopped coming back.
Tableau
Recency days: DATEDIFF('day', { FIXED [customer_id] : MAX([order_date]) }, { FIXED : MAX([order_date]) }); Frequency: { FIXED [customer_id] : COUNTD([order_id]) }; Monetary: { FIXED [customer_id] : SUM([revenue]) }SQL
WITH c AS (
SELECT
customer_id,
ANY_VALUE(customer) AS customer,
date_diff('day', MAX(order_date), (
SELECT MAX(order_date)
FROM input
)) AS recency_days,
COUNT(DISTINCT order_id) AS frequency,
SUM(revenue) AS monetary
FROM input
GROUP BY customer_id
)
SELECT customer, recency_days, frequency, monetary
FROM c
ORDER BY monetary DESCRows
| Customer | Recency days | Frequency | Revenue |
|---|---|---|---|
| Sana Duarte | 22 | 15 | $1,625 |
| Vera Lind | 34 | 14 | $1,591 |
| Tess Duarte | 23 | 14 | $1,586 |
| Olive Ito | 20 | 14 | $1,550 |
| Grace Walsh | 14 | 15 | $1,493 |
| Iris Lind | 8 | 13 | $1,375 |
| Ruth Lind | 23 | 13 | $1,347 |
| Lucia Lind | 280 | 11 | $1,321 |
| Olive Moreau | 22 | 12 | $1,317 |
| Vera Olsen | 20 | 12 | $1,302 |
| Sofia Patel | 3 | 12 | $1,287 |
| Tilde Fischer | 22 | 12 | $1,252 |
| Aiko Zhou | 16 | 12 | $1,235 |
| Lucia Novak | 5 | 12 | $1,209 |
| Vera Patel | 0 | 11 | $1,193 |
| Ingrid Garcia | 0 | 11 | $1,186 |
| Tess Zhou | 305 | 10 | $1,185 |
| Olive Rossi | 278 | 10 | $1,156 |
| Ava Okafor | 317 | 10 | $1,142 |
| Iris Ito | 269 | 11 | $1,140 |
| Moira Berg | 252 | 10 | $1,120 |
| Aiko Novak | 5 | 10 | $1,108 |
| Kit Murphy | 271 | 9 | $1,106 |
| Wren Duarte | 310 | 9 | $1,006 |
| Ava Quist | 16 | 9 | $1,003 |
| Lucia Quist | 2 | 10 | $1,001 |
| Rhea Berg | 284 | 9 | $994 |
| Aiko Duarte | 330 | 9 | $972 |
| Noor Rossi | 34 | 9 | $962 |
| Pia Olsen | 9 | 9 | $955 |
| Uma Garcia | 23 | 9 | $953 |
| Yara Tanaka | 373 | 8 | $947 |
| Nell Larsen | 287 | 8 | $917 |
| Tess Larsen | 311 | 8 | $912 |
| Elena Ito | 3 | 9 | $894 |
| Grace Rossi | 7 | 8 | $891 |
| Elena Murphy | 267 | 8 | $872 |
| Wren Novak | 39 | 8 | $870 |
| Noor Ueda | 296 | 8 | $866 |
| Leila Murphy | 2 | 8 | $854 |
| Moira Jensen | 365 | 8 | $846 |
| Ingrid Murphy | 341 | 7 | $835 |
| Quinn Rossi | 4 | 8 | $816 |
| Clara Olsen | 4 | 8 | $799 |
| Uma Ueda | 28 | 8 | $798 |
| Joan Evans | 24 | 8 | $776 |
| Noor Moreau | 20 | 8 | $769 |
| Sky Okafor | 309 | 6 | $758 |
| Sofia Fischer | 296 | 6 | $732 |
| Kit Berg | 233 | 6 | $700 |
First 50 of 380 rows.
Step 2
Score each number 1 to 5
Recency and spend use quintile cut-offs: the 20th, 40th, 60th and 80th percentile across customers, so each score holds about a fifth of them. Recency is reversed: fewer days scores higher. Frequency uses fixed tiers instead, because almost half the customers ordered exactly once and quintiles can't split a tie.
Tableau
R score: IF [Recency days] <= { FIXED : PERCENTILE({ INCLUDE [customer_id] : MAX([Recency days]) }, 0.2) } THEN 5
ELSEIF ... 0.4 ... THEN 4
ELSEIF ... 0.6 ... THEN 3
ELSEIF ... 0.8 ... THEN 2
ELSE 1
END; F score: IF [Frequency] >= 6 THEN 5
ELSEIF [Frequency] >= 4 THEN 4
ELSEIF [Frequency] = 3 THEN 3
ELSEIF [Frequency] = 2 THEN 2
ELSE 1
END; M score: as R, on { INCLUDE [customer_id] : SUM([revenue]) }, with >= and 5 at the topSQL
WITH c AS (
SELECT
customer_id,
ANY_VALUE(customer) AS customer,
date_diff('day', MAX(order_date), (
SELECT MAX(order_date)
FROM input
)) AS recency_days,
COUNT(DISTINCT order_id) AS frequency,
SUM(revenue) AS monetary
FROM input
GROUP BY customer_id
), q AS (
SELECT
quantile_cont(recency_days, [0.2, 0.4, 0.6, 0.8]) AS r,
quantile_cont(monetary, [0.2, 0.4, 0.6, 0.8]) AS m
FROM c
), s AS (
SELECT
c.*,
CASE
WHEN recency_days <= r [1] THEN 5
WHEN recency_days <= r [2] THEN 4
WHEN recency_days <= r [3] THEN 3
WHEN recency_days <= r [4] THEN 2
ELSE 1
END AS r_score,
CASE
WHEN frequency >= 6 THEN 5
WHEN frequency >= 4 THEN 4
WHEN frequency = 3 THEN 3
WHEN frequency = 2 THEN 2
ELSE 1
END AS f_score,
CASE
WHEN monetary >= m [4] THEN 5
WHEN monetary >= m [3] THEN 4
WHEN monetary >= m [2] THEN 3
WHEN monetary >= m [1] THEN 2
ELSE 1
END AS m_score
FROM c CROSS JOIN q
)
SELECT
'Recency' AS score_of,
r_score AS score,
COUNT(*) : : INTEGER AS customers
FROM s
GROUP BY 2
UNION ALL
SELECT 'Frequency', f_score, COUNT(*) : : INTEGER
FROM s
GROUP BY 2
UNION ALL
SELECT 'Monetary', m_score, COUNT(*) : : INTEGER
FROM s
GROUP BY 2
ORDER BY 1, 2Rows
| Score of | Score | Customers |
|---|---|---|
| Frequency | 1 | 181 |
| Frequency | 2 | 53 |
| Frequency | 3 | 41 |
| Frequency | 4 | 13 |
| Frequency | 5 | 92 |
| Monetary | 1 | 76 |
| Monetary | 2 | 76 |
| Monetary | 3 | 76 |
| Monetary | 4 | 76 |
| Monetary | 5 | 76 |
| Recency | 1 | 75 |
| Recency | 2 | 77 |
| Recency | 3 | 75 |
| Recency | 4 | 76 |
| Recency | 5 | 77 |
Common mistake
Quintiles on frequency
NTILE(5) forces five equal groups, so customers with the same single order land in scores 1, 2 and 3 by chance, and a six-order customer can be a 4 or a 5. Use fixed tiers for counts with heavy ties.
SQL
WITH c AS (
SELECT customer_id, COUNT(DISTINCT order_id) AS frequency
FROM input
GROUP BY customer_id
), n AS (
SELECT frequency, NTILE(5) OVER (ORDER BY frequency) AS f_score
FROM c
)
SELECT
f_score,
MIN(frequency) AS fewest_orders,
MAX(frequency) AS most_orders,
COUNT(*) : : INTEGER AS customers
FROM n
GROUP BY f_score
ORDER BY f_scoreSQL
WITH c AS (
SELECT customer_id, COUNT(DISTINCT order_id) AS frequency
FROM input
GROUP BY customer_id
), n AS (
SELECT
frequency,
CASE
WHEN frequency >= 6 THEN 5
WHEN frequency >= 4 THEN 4
WHEN frequency = 3 THEN 3
WHEN frequency = 2 THEN 2
ELSE 1
END AS f_score
FROM c
)
SELECT
f_score,
MIN(frequency) AS fewest_orders,
MAX(frequency) AS most_orders,
COUNT(*) : : INTEGER AS customers
FROM n
GROUP BY f_score
ORDER BY f_scoreStep 3
Name segments from the scores
Rules are checked in order and each customer gets the first one they match. At risk means: used to buy often, hasn't lately. Here it is the second-biggest segment by revenue.
Tableau
IF [R score] >= 4 AND [F score] >= 4 AND [M score] >= 4 THEN 'Champions'
ELSEIF [R score] >= 3 AND [F score] >= 4 THEN 'Loyal'
ELSEIF [R score] >= 4 AND [F score] = 1 THEN 'New'
ELSEIF [R score] <= 2 AND [F score] >= 3 THEN 'At risk'
ELSEIF [R score] <= 2 THEN 'Hibernating'
ELSE 'Needs attention'
ENDSQL
WITH c AS (
SELECT
customer_id,
ANY_VALUE(customer) AS customer,
date_diff('day', MAX(order_date), (
SELECT MAX(order_date)
FROM input
)) AS recency_days,
COUNT(DISTINCT order_id) AS frequency,
SUM(revenue) AS monetary
FROM input
GROUP BY customer_id
), q AS (
SELECT
quantile_cont(recency_days, [0.2, 0.4, 0.6, 0.8]) AS r,
quantile_cont(monetary, [0.2, 0.4, 0.6, 0.8]) AS m
FROM c
), s AS (
SELECT
c.*,
CASE
WHEN recency_days <= r [1] THEN 5
WHEN recency_days <= r [2] THEN 4
WHEN recency_days <= r [3] THEN 3
WHEN recency_days <= r [4] THEN 2
ELSE 1
END AS r_score,
CASE
WHEN frequency >= 6 THEN 5
WHEN frequency >= 4 THEN 4
WHEN frequency = 3 THEN 3
WHEN frequency = 2 THEN 2
ELSE 1
END AS f_score,
CASE
WHEN monetary >= m [4] THEN 5
WHEN monetary >= m [3] THEN 4
WHEN monetary >= m [2] THEN 3
WHEN monetary >= m [1] THEN 2
ELSE 1
END AS m_score
FROM c CROSS JOIN q
), g AS (
SELECT
*,
CASE
WHEN r_score >= 4 AND f_score >= 4 AND m_score >= 4 THEN 'Champions'
WHEN r_score >= 3 AND f_score >= 4 THEN 'Loyal'
WHEN r_score >= 4 AND f_score = 1 THEN 'New'
WHEN r_score <= 2 AND f_score >= 3 THEN 'At risk'
WHEN r_score <= 2 THEN 'Hibernating'
ELSE 'Needs attention'
END AS segment
FROM s
)
SELECT
segment,
COUNT(*) : : INTEGER AS customers,
SUM(monetary) AS revenue,
SUM(monetary)
/ (
SELECT SUM(monetary)
FROM g
) AS share_of_revenue
FROM g
GROUP BY segment
ORDER BY revenue DESCRows
| Segment | Customers | Monetary | Share of revenue |
|---|---|---|---|
| Champions | 63 | 46,205 | 49.3% |
| At risk | 38 | 26,857 | 28.7% |
| Needs attention | 104 | 9,766 | 10.4% |
| Hibernating | 114 | 4,997 | 5.3% |
| Loyal | 12 | 3,82 | 4.1% |
| New | 49 | 2,066 | 2.2% |
Are my biggest spenders my best customers?
Not necessarily. Champions carry 49.3% of revenue here, but At risk is next with 28.7%: customers who used to buy often and have stopped. Rank by spend alone and they sit at the top of your list, looking safe. Recency is the number that tells you they're leaving.
Step 4
Check your top spenders for silence
Take the top fifth by spend, the list a spend-only ranking would hand you as "best customers", and split it by days since the last order. Move the slider: the quiet share stays large well past half a year.
Tableau
IF [Recency days] > 180 THEN 'Quiet'
ELSE 'Active'
END
// Filter: [M score] = 5SQL
WITH c AS (
SELECT
customer_id,
ANY_VALUE(customer) AS customer,
date_diff('day', MAX(order_date), (
SELECT MAX(order_date)
FROM input
)) AS recency_days,
COUNT(DISTINCT order_id) AS frequency,
SUM(revenue) AS monetary
FROM input
GROUP BY customer_id
), q AS (
SELECT
quantile_cont(recency_days, [0.2, 0.4, 0.6, 0.8]) AS r,
quantile_cont(monetary, [0.2, 0.4, 0.6, 0.8]) AS m
FROM c
), s AS (
SELECT
c.*,
CASE
WHEN recency_days <= r [1] THEN 5
WHEN recency_days <= r [2] THEN 4
WHEN recency_days <= r [3] THEN 3
WHEN recency_days <= r [4] THEN 2
ELSE 1
END AS r_score,
CASE
WHEN frequency >= 6 THEN 5
WHEN frequency >= 4 THEN 4
WHEN frequency = 3 THEN 3
WHEN frequency = 2 THEN 2
ELSE 1
END AS f_score,
CASE
WHEN monetary >= m [4] THEN 5
WHEN monetary >= m [3] THEN 4
WHEN monetary >= m [2] THEN 3
WHEN monetary >= m [1] THEN 2
ELSE 1
END AS m_score
FROM c CROSS JOIN q
), g AS (
SELECT
*,
CASE
WHEN r_score >= 4 AND f_score >= 4 AND m_score >= 4 THEN 'Champions'
WHEN r_score >= 3 AND f_score >= 4 THEN 'Loyal'
WHEN r_score >= 4 AND f_score = 1 THEN 'New'
WHEN r_score <= 2 AND f_score >= 3 THEN 'At risk'
WHEN r_score <= 2 THEN 'Hibernating'
ELSE 'Needs attention'
END AS segment
FROM s
)
SELECT
CASE
WHEN recency_days > 180 THEN 'Quiet for over {knob.quiet_days} days'
ELSE 'Ordered in the last {knob.quiet_days} days'
END AS status,
COUNT(*) : : INTEGER AS customers,
SUM(monetary) AS revenue,
SUM(monetary) / SUM(SUM(monetary)) OVER () AS share_of_top_spend
FROM g
WHERE m_score = 5
GROUP BY status
ORDER BY statusRows
| Status | Customers | Monetary | Share of top spend |
|---|---|---|---|
| Ordered in the last 180 days | 46 | $40,709 | 61.3% |
| Quiet for over 180 days | 30 | $25,700 | 38.7% |