← Back to Dimensions

RFM Segment

Also known as: RFM Group, RFM Customer Segment, rfm_segment

RetentiondimensionE-commerce: Customer Retention, Loyalty & LTVretentionsegmentationltvE-commerce

RFM Segment is the named behavioral segment a customer holds on the scoring snapshot date, built from Recency, Frequency and Monetary scores of 1–5 assigned by customer-count quintile across every customer with at least one qualifying order in the trailing scoring window, with the R×F score combination mapped to a governed segment list whose documented default is the 11-segment map from Champions to Lost. Lifecycle marketing, retention and finance teams use it to target, suppress and prioritise customer groups and to track migration between segments from one refresh to the next, and it is distinct from Customer Status (relationship or lifecycle state), Customer Loyalty Tier (a tier earned under a loyalty program) and Customer Segmentation by Risk Profile (financial or credit risk).

Business Use Case

1️⃣ CRM & Lifecycle Marketing manager: after each weekly refresh, customers who moved from Champions or Loyal Customers into At Risk or Cannot Lose Them enter the win-back journey (personal outreach, early access, then an incentive only at step three). Champions are excluded from blanket discount campaigns to protect margin. Win-back success is reviewed monthly as the share of enrolled customers back in Loyal Customers or better at the next refresh. 2️⃣ Performance Marketing lead: on the weekly audience sync, Champions and Loyal Customers are uploaded as seed lists for lookalike and value-based bidding audiences. Lost and Hibernating customers go into suppression lists for full-price retargeting. When the Lost segment exceeds 25% of scored customers, budget is moved from retargeting to prospecting. 3️⃣ Head of Retention with Finance (FP&A): at the monthly retention review, read the segment migration matrix alongside Customer Retention Rate, Repeat Purchase Rate and Customer Churn Rate. When the share of trailing-window net revenue from At Risk and Cannot Lose Them rises by more than 3 percentage points month over month, flag the exposure in the Customer Lifetime Value forecast and approve extra retention budget for those segments. 4️⃣ Customer Service team lead: when an inbound contact comes from a customer whose CRM record shows Champions or Cannot Lose Them, route it to the priority queue with a shorter first-response target and appeasement authority. Resolution time and repeat contacts for these segments are reported at the weekly service review.

Business Criticality

High

Journey / Navigation Area

Generic

Owning Function

Marketing

Scope

User

Calculation Notes

1️⃣ Scoring window and base: the default window is the trailing 24 months ending at the snapshot date (end of day in the store's reporting time zone). Merchants in high-frequency categories such as grocery, beauty replenishment or pet supplies may govern a trailing 12-month window instead. The scoring base is every resolved customer with at least one qualifying order in the window. Customers whose last qualifying order falls before the window are outside the base: they are labelled '(not scored)', never forced into Lost, and they do not shift the quintile cut-offs. 2️⃣ Measures: Recency = days from the last qualifying order date to the snapshot date (the same quantity as Days Since Last Purchase). Frequency = count of distinct qualifying orders in the window (Orders per Customer at customer grain). Monetary = net merchandise revenue in the window after discounts, returns and refunds, excluding tax, shipping and gift card purchases, converted to the reporting currency at the order-date rate. A qualifying order is placed and not cancelled, not fully refunded, and has net merchandise value above zero. Test, draft, zero-value replacement and free-sample orders are removed. Partial refunds reduce Monetary but do not remove the order from Frequency. 3️⃣ Quintile basis: each measure is ranked across the scoring base and split into five customer-count quintiles, each holding about 20% of scored customers. 5 is best on every axis: most recent, most orders, highest net revenue, so Recency is ranked ascending on days. Tied values always get the same score, taking the quintile of the first customer in the tie. Because single-order customers usually make up more than half of an e-commerce base, F scores 1 to 3 often collapse into one value. When that happens, Frequency is scored on documented fixed order-count cut-offs (for example 1, 2, 3, 4–5, 6+) instead of forced quintiles, and the cut-off method is stored with the snapshot. Cut-offs are recalculated at every refresh and are never carried over from an earlier run. 4️⃣ Default 11-segment R×F map (cells given as R score, F score): Champions = R5 F4–5; Loyal Customers = R3–4 F4–5; Potential Loyalists = R4–5 F2–3; New Customers = R5 F1; Promising = R4 F1; Need Attention = R3 F3; About to Sleep = R3 F1–2; At Risk = R1–2 F3–4; Cannot Lose Them = R1–2 F5; Hibernating = R2 F1–2; Lost = R1 F1–2. All 25 cells map to exactly one segment. The label does not use the M score. M is kept as a separate score for ranking customers inside a segment, for example ordering Cannot Lose Them outreach by M. No composite RFM score, weighting or concatenated code is reported as the value. 5️⃣ Configurable map and variants: the segment map is governed reference data with a map_version, and every snapshot row records the version it was scored with, so trends are only compared within one version. The shorter Klaviyo variant uses Champions, Loyal, Recent, Needs Attention, At Risk and Inactive. When both are reported together, the crosswalk from the default map is: Champions → Champions; Loyal Customers and Potential Loyalists → Loyal; New Customers and Promising → Recent; Need Attention and About to Sleep → Needs Attention; At Risk and Cannot Lose Them → At Risk; Hibernating and Lost → Inactive. Segments calculated inside Klaviyo keep Klaviyo's own thresholds and are not re-scored. 6️⃣ Identity and exclusions: the customer key is the commerce platform customer ID (Shopify customer.id, Adobe Commerce sales_order.customer_id, Salesforce Commerce Cloud customerNo), reconciled to the CRM customer master. Guest orders are linked to a registered customer by normalised, hashed email. A guest email with no account becomes its own customer key and is never dropped. Employee, internal, reseller, B2B and wholesale accounts are removed before ranking because their volumes distort the F and M quintiles. Merged customer records are re-scored as one customer from the merge date onward. Marketplace orders without a merchant-owned customer identity are excluded. 7️⃣ Timing and use in journeys: segments are rebuilt as a batch job on a schedule (weekly by default, daily for high-volume merchants) and stored as dated snapshots, so migration analysis such as Champions → At Risk is never restated. Event-level and session-level analysis joins the segment in effect on the event date, never today's value. When the segment is exposed as a user property (rfm_segment), it is set at login or identity resolution and applies only to hits from that point forward. Scoring runs server-side and never starts or extends a session, so the 30-minute inactivity timeout does not affect it. Fixed-threshold views of the same inputs belong to the sibling dimensions last-purchase-recency-band and purchase-count-band, not to this label.

Technical Details

Data Typestring
Lookback WindowUser, Weekly, Monthly

Events

SQL Queries

Google Analytics 4 (BigQuery)
-- ==========================================
-- 1. GOOGLE BIGQUERY SQL
-- ==========================================
-- RFM Segment: one row per in-scope customer per snapshot date, built from backend commerce orders and the CRM customer master (no web events).
-- R = days since last qualifying order (reversed so most recent = 5), F = distinct qualifying orders, M = net merchandise revenue; all in the trailing scoring window.
WITH params AS (
  SELECT
    DATE '2026-09-13' AS snapshot_start_date,  -- first snapshot date (end of day, reporting time zone)
    DATE '2026-09-13' AS snapshot_end_date,    -- last snapshot date; weekly refresh steps in between
    24 AS window_months,                       -- governed alternative for high-frequency categories: 12
    'America/New_York' AS reporting_tz,
    'USD' AS reporting_currency,
    'rfm-11-default-v1' AS map_version
),
snapshots AS (
  SELECT
    snapshot_date,
    p.window_months,
    p.reporting_tz,
    p.reporting_currency,
    p.map_version,
    DATE_SUB(snapshot_date, INTERVAL p.window_months MONTH) AS window_start_exclusive
  FROM params AS p,
    UNNEST(GENERATE_DATE_ARRAY(p.snapshot_start_date, p.snapshot_end_date, INTERVAL 7 DAY)) AS snapshot_date
),
crm_customers AS (
  SELECT
    CAST(c.customer_id AS STRING) AS crm_customer_id,
    CAST(c.commerce_customer_id AS STRING) AS commerce_customer_id,
    TO_HEX(SHA256(LOWER(TRIM(c.email)))) AS email_hash,
    CAST(c.merged_into_customer_id AS STRING) AS merged_into_customer_id,
    c.merged_at,
    c.created_at,
    LOWER(c.account_type) AS account_type
  FROM `your-project.crm.customers` AS c
),
-- Merged records roll up to the surviving customer from the merge date onward
crm_resolved AS (
  SELECT
    s.snapshot_date,
    c.crm_customer_id,
    c.commerce_customer_id,
    c.email_hash,
    c.created_at,
    c.account_type,
    IF(c.merged_into_customer_id IS NOT NULL AND DATE(c.merged_at, s.reporting_tz) <= s.snapshot_date,
       c.merged_into_customer_id, c.crm_customer_id) AS customer_key
  FROM crm_customers AS c
  CROSS JOIN snapshots AS s
  WHERE DATE(c.created_at, s.reporting_tz) <= s.snapshot_date
),
commerce_id_match AS (
  SELECT snapshot_date, commerce_customer_id, customer_key
  FROM crm_resolved
  WHERE commerce_customer_id IS NOT NULL
  QUALIFY ROW_NUMBER() OVER (PARTITION BY snapshot_date, commerce_customer_id ORDER BY created_at, crm_customer_id) = 1
),
email_match AS (
  SELECT snapshot_date, email_hash, customer_key
  FROM crm_resolved
  WHERE email_hash IS NOT NULL
  QUALIFY ROW_NUMBER() OVER (PARTITION BY snapshot_date, email_hash ORDER BY created_at, crm_customer_id) = 1
),
-- Employee, internal, reseller, B2B and wholesale accounts are removed before ranking
excluded_keys AS (
  SELECT snapshot_date, customer_key
  FROM crm_resolved
  GROUP BY snapshot_date, customer_key
  HAVING LOGICAL_OR(account_type IN ('employee', 'internal', 'reseller', 'b2b', 'wholesale'))
),
refunds_to_date AS (
  SELECT
    s.snapshot_date,
    CAST(r.order_id AS STRING) AS order_id,
    SUM(r.refund_merchandise_amount) AS refunded_merchandise_amount
  FROM `your-project.commerce.order_refunds` AS r
  CROSS JOIN snapshots AS s
  WHERE DATE(r.refunded_at, s.reporting_tz) <= s.snapshot_date
  GROUP BY s.snapshot_date, CAST(r.order_id AS STRING)
),
orders_to_date AS (
  SELECT
    s.snapshot_date,
    s.window_start_exclusive,
    s.reporting_currency,
    CAST(o.order_id AS STRING) AS order_id,
    CAST(o.customer_id AS STRING) AS commerce_customer_id,
    TO_HEX(SHA256(LOWER(TRIM(o.customer_email)))) AS email_hash,
    DATE(o.order_placed_at, s.reporting_tz) AS order_date,
    o.currency_code,
    -- merchandise only: excludes tax, shipping and gift card purchases
    COALESCE(o.merchandise_subtotal_amount, 0)
      - COALESCE(o.order_discount_amount, 0)
      - COALESCE(o.gift_card_sales_amount, 0) AS merchandise_after_discount
  FROM `your-project.commerce.orders` AS o
  CROSS JOIN snapshots AS s
  WHERE DATE(o.order_placed_at, s.reporting_tz) <= s.snapshot_date
    AND LOWER(o.order_status) NOT IN ('cancelled', 'canceled', 'voided', 'draft')
    AND COALESCE(o.is_test_order, FALSE) = FALSE
    AND COALESCE(o.is_staff_order, FALSE) = FALSE
    AND LOWER(COALESCE(o.order_type, 'standard')) NOT IN ('free_sample', 'test', 'draft')
    AND NOT (COALESCE(o.is_marketplace_order, FALSE) AND o.customer_id IS NULL)
),
-- Qualifying order: net merchandise value above zero after refunds posted by the snapshot date
-- (removes fully refunded and zero-value replacement orders; partial refunds keep the order and reduce M)
qualifying_orders AS (
  SELECT
    o.snapshot_date,
    o.window_start_exclusive,
    o.order_id,
    o.order_date,
    COALESCE(cm.customer_key, em.customer_key, o.commerce_customer_id, CONCAT('guest:', o.email_hash)) AS customer_key,
    (o.merchandise_after_discount - COALESCE(rf.refunded_merchandise_amount, 0))
      * IF(o.currency_code = o.reporting_currency, 1, fx.rate) AS net_revenue_reporting
  FROM orders_to_date AS o
  LEFT JOIN refunds_to_date AS rf
    ON rf.snapshot_date = o.snapshot_date AND rf.order_id = o.order_id
  LEFT JOIN commerce_id_match AS cm
    ON cm.snapshot_date = o.snapshot_date AND cm.commerce_customer_id = o.commerce_customer_id
  LEFT JOIN email_match AS em
    ON em.snapshot_date = o.snapshot_date AND em.email_hash = o.email_hash AND o.commerce_customer_id IS NULL
  LEFT JOIN `your-project.finance.fx_daily_rates` AS fx
    ON fx.rate_date = o.order_date AND fx.from_currency = o.currency_code AND fx.to_currency = o.reporting_currency
  WHERE o.merchandise_after_discount - COALESCE(rf.refunded_merchandise_amount, 0) > 0
    AND (o.commerce_customer_id IS NOT NULL OR o.email_hash IS NOT NULL)
),
-- Base population: every in-scope customer with at least one qualifying order up to the snapshot date
customer_measures AS (
  SELECT
    q.snapshot_date,
    q.customer_key,
    DATE_DIFF(q.snapshot_date, MAX(q.order_date), DAY) AS recency_days,
    COUNT(DISTINCT IF(q.order_date > q.window_start_exclusive, q.order_id, NULL)) AS frequency_orders,
    ROUND(SUM(IF(q.order_date > q.window_start_exclusive, q.net_revenue_reporting, 0)), 2) AS monetary_net_revenue
  FROM qualifying_orders AS q
  LEFT JOIN excluded_keys AS x
    ON x.snapshot_date = q.snapshot_date AND x.customer_key = q.customer_key
  WHERE x.customer_key IS NULL
  GROUP BY q.snapshot_date, q.customer_key
),
-- Customer-count quintiles over the scoring base only (at least one qualifying order in the window)
quintile_tiles AS (
  SELECT
    m.*,
    NTILE(5) OVER (PARTITION BY m.snapshot_date ORDER BY m.recency_days DESC, m.customer_key) AS r_tile,
    NTILE(5) OVER (PARTITION BY m.snapshot_date ORDER BY m.frequency_orders ASC, m.customer_key) AS f_tile,
    NTILE(5) OVER (PARTITION BY m.snapshot_date ORDER BY m.monetary_net_revenue ASC, m.customer_key) AS m_tile
  FROM customer_measures AS m
  WHERE m.frequency_orders > 0
),
-- Tied values share one score: the quintile of the first customer in the tie
tie_adjusted AS (
  SELECT
    t.*,
    MIN(t.r_tile) OVER (PARTITION BY t.snapshot_date, t.recency_days) AS r_quintile,
    MIN(t.f_tile) OVER (PARTITION BY t.snapshot_date, t.frequency_orders) AS f_quintile,
    MIN(t.m_tile) OVER (PARTITION BY t.snapshot_date, t.monetary_net_revenue) AS m_quintile
  FROM quintile_tiles AS t
),
-- F scores 1 to 3 collapsed into one value when no customer holds F quintile 2 or 3
f_method AS (
  SELECT snapshot_date, COUNTIF(f_quintile IN (2, 3)) = 0 AS use_fixed_f_cutoffs
  FROM tie_adjusted
  GROUP BY snapshot_date
),
scores AS (
  SELECT
    t.snapshot_date,
    t.customer_key,
    t.r_quintile AS r_score,
    IF(fm.use_fixed_f_cutoffs,
       CASE
         WHEN t.frequency_orders >= 6 THEN 5
         WHEN t.frequency_orders >= 4 THEN 4
         WHEN t.frequency_orders = 3 THEN 3
         WHEN t.frequency_orders = 2 THEN 2
         ELSE 1
       END,
       t.f_quintile) AS f_score,
    t.m_quintile AS m_score,
    t.monetary_net_revenue,
    IF(fm.use_fixed_f_cutoffs, 'fixed_cutoffs_1|2|3|4-5|6+', 'quintile') AS f_score_method
  FROM tie_adjusted AS t
  JOIN f_method AS fm
    ON fm.snapshot_date = t.snapshot_date
),
-- Default 11-segment R x F map (all 25 cells); M is used only for ranking inside a segment
segmented AS (
  SELECT
    sc.*,
    CASE
      WHEN sc.r_score = 5 AND sc.f_score IN (4, 5) THEN 'Champions'
      WHEN sc.r_score IN (3, 4) AND sc.f_score IN (4, 5) THEN 'Loyal Customers'
      WHEN sc.r_score IN (4, 5) AND sc.f_score IN (2, 3) THEN 'Potential Loyalists'
      WHEN sc.r_score = 5 AND sc.f_score = 1 THEN 'New Customers'
      WHEN sc.r_score = 4 AND sc.f_score = 1 THEN 'Promising'
      WHEN sc.r_score = 3 AND sc.f_score = 3 THEN 'Need Attention'
      WHEN sc.r_score = 3 AND sc.f_score IN (1, 2) THEN 'About to Sleep'
      WHEN sc.r_score IN (1, 2) AND sc.f_score IN (3, 4) THEN 'At Risk'
      WHEN sc.r_score IN (1, 2) AND sc.f_score = 5 THEN 'Cannot Lose Them'
      WHEN sc.r_score = 2 AND sc.f_score IN (1, 2) THEN 'Hibernating'
      WHEN sc.r_score = 1 AND sc.f_score IN (1, 2) THEN 'Lost'
    END AS rfm_segment
  FROM scores AS sc
),
ranked AS (
  SELECT
    sg.*,
    ROW_NUMBER() OVER (
      PARTITION BY sg.snapshot_date, sg.rfm_segment
      ORDER BY sg.m_score DESC, sg.monetary_net_revenue DESC, sg.customer_key
    ) AS monetary_rank_in_segment
  FROM segmented AS sg
)
SELECT
  m.snapshot_date,
  m.customer_key,
  COALESCE(rk.rfm_segment, '(not scored)') AS rfm_segment,
  rk.r_score,
  rk.f_score,
  rk.m_score,
  rk.monetary_rank_in_segment,
  rk.f_score_method,
  m.recency_days,
  m.frequency_orders,
  m.monetary_net_revenue,
  s.window_months AS scoring_window_months,
  DATE_ADD(s.window_start_exclusive, INTERVAL 1 DAY) AS scoring_window_start_date,
  s.map_version
FROM customer_measures AS m
JOIN snapshots AS s
  ON s.snapshot_date = m.snapshot_date
LEFT JOIN ranked AS rk
  ON rk.snapshot_date = m.snapshot_date AND rk.customer_key = m.customer_key
ORDER BY m.snapshot_date, COALESCE(rk.rfm_segment, '(not scored)'), rk.monetary_rank_in_segment, m.customer_key;
Adobe Experience Platform (AEP Query Service)
-- ==========================================
-- 2. ADOBE AEP QUERY SERVICE SQL
-- ==========================================
-- RFM Segment: one row per in-scope customer per snapshot date, built from backend commerce order and CRM customer profile datasets (no web events).
-- R = days since last qualifying order (reversed so most recent = 5), F = distinct qualifying orders, M = net merchandise revenue; all in the trailing scoring window.
WITH params AS (
  SELECT
    to_date('2026-09-13') AS snapshot_start_date,  -- first snapshot date (end of day, reporting time zone)
    to_date('2026-09-13') AS snapshot_end_date,    -- last snapshot date; weekly refresh steps in between
    24 AS window_months,                           -- governed alternative for high-frequency categories: 12
    'America/New_York' AS reporting_tz,
    'USD' AS reporting_currency,
    'rfm-11-default-v1' AS map_version
),
snapshot_calendar AS (
  SELECT explode(sequence(snapshot_start_date, snapshot_end_date, INTERVAL 7 DAYS)) AS snapshot_date
  FROM params
),
snapshots AS (
  SELECT
    c.snapshot_date,
    p.window_months,
    p.reporting_tz,
    p.reporting_currency,
    p.map_version,
    add_months(c.snapshot_date, -p.window_months) AS window_start_exclusive
  FROM snapshot_calendar AS c
  CROSS JOIN params AS p
),
crm_customers AS (
  SELECT
    CAST(c.customer_id AS STRING) AS crm_customer_id,
    CAST(c.commerce_customer_id AS STRING) AS commerce_customer_id,
    sha2(lower(trim(c.email)), 256) AS email_hash,
    CAST(c.merged_into_customer_id AS STRING) AS merged_into_customer_id,
    c.merged_at,
    c.created_at,
    lower(c.account_type) AS account_type
  FROM crm_customer_profile_dataset AS c
),
-- Merged records roll up to the surviving customer from the merge date onward
crm_resolved AS (
  SELECT
    s.snapshot_date,
    c.crm_customer_id,
    c.commerce_customer_id,
    c.email_hash,
    c.created_at,
    c.account_type,
    CASE
      WHEN c.merged_into_customer_id IS NOT NULL
       AND to_date(from_utc_timestamp(c.merged_at, s.reporting_tz)) <= s.snapshot_date
      THEN c.merged_into_customer_id
      ELSE c.crm_customer_id
    END AS customer_key
  FROM crm_customers AS c
  CROSS JOIN snapshots AS s
  WHERE to_date(from_utc_timestamp(c.created_at, s.reporting_tz)) <= s.snapshot_date
),
commerce_id_match AS (
  SELECT snapshot_date, commerce_customer_id, customer_key
  FROM (
    SELECT
      r.snapshot_date,
      r.commerce_customer_id,
      r.customer_key,
      ROW_NUMBER() OVER (PARTITION BY r.snapshot_date, r.commerce_customer_id ORDER BY r.created_at, r.crm_customer_id) AS rn
    FROM crm_resolved AS r
    WHERE r.commerce_customer_id IS NOT NULL
  ) AS x
  WHERE rn = 1
),
email_match AS (
  SELECT snapshot_date, email_hash, customer_key
  FROM (
    SELECT
      r.snapshot_date,
      r.email_hash,
      r.customer_key,
      ROW_NUMBER() OVER (PARTITION BY r.snapshot_date, r.email_hash ORDER BY r.created_at, r.crm_customer_id) AS rn
    FROM crm_resolved AS r
    WHERE r.email_hash IS NOT NULL
  ) AS x
  WHERE rn = 1
),
-- Employee, internal, reseller, B2B and wholesale accounts are removed before ranking
excluded_keys AS (
  SELECT snapshot_date, customer_key
  FROM crm_resolved
  GROUP BY snapshot_date, customer_key
  HAVING MAX(CASE WHEN account_type IN ('employee', 'internal', 'reseller', 'b2b', 'wholesale') THEN 1 ELSE 0 END) = 1
),
refunds_to_date AS (
  SELECT
    s.snapshot_date,
    CAST(r.order_id AS STRING) AS order_id,
    SUM(r.refund_merchandise_amount) AS refunded_merchandise_amount
  FROM commerce_order_refunds_dataset AS r
  CROSS JOIN snapshots AS s
  WHERE to_date(from_utc_timestamp(r.refunded_at, s.reporting_tz)) <= s.snapshot_date
  GROUP BY s.snapshot_date, CAST(r.order_id AS STRING)
),
orders_to_date AS (
  SELECT
    s.snapshot_date,
    s.window_start_exclusive,
    s.reporting_currency,
    CAST(o.order_id AS STRING) AS order_id,
    CAST(o.customer_id AS STRING) AS commerce_customer_id,
    sha2(lower(trim(o.customer_email)), 256) AS email_hash,
    to_date(from_utc_timestamp(o.order_placed_at, s.reporting_tz)) AS order_date,
    o.currency_code,
    -- merchandise only: excludes tax, shipping and gift card purchases
    COALESCE(o.merchandise_subtotal_amount, 0)
      - COALESCE(o.order_discount_amount, 0)
      - COALESCE(o.gift_card_sales_amount, 0) AS merchandise_after_discount
  FROM commerce_orders_dataset AS o
  CROSS JOIN snapshots AS s
  WHERE to_date(from_utc_timestamp(o.order_placed_at, s.reporting_tz)) <= s.snapshot_date
    AND lower(o.order_status) NOT IN ('cancelled', 'canceled', 'voided', 'draft')
    AND COALESCE(o.is_test_order, false) = false
    AND COALESCE(o.is_staff_order, false) = false
    AND lower(COALESCE(o.order_type, 'standard')) NOT IN ('free_sample', 'test', 'draft')
    AND NOT (COALESCE(o.is_marketplace_order, false) AND o.customer_id IS NULL)
),
-- Qualifying order: net merchandise value above zero after refunds posted by the snapshot date
-- (removes fully refunded and zero-value replacement orders; partial refunds keep the order and reduce M)
qualifying_orders AS (
  SELECT
    o.snapshot_date,
    o.window_start_exclusive,
    o.order_id,
    o.order_date,
    COALESCE(cm.customer_key, em.customer_key, o.commerce_customer_id, concat('guest:', o.email_hash)) AS customer_key,
    (o.merchandise_after_discount - COALESCE(rf.refunded_merchandise_amount, 0))
      * CASE WHEN o.currency_code = o.reporting_currency THEN 1 ELSE fx.rate END AS net_revenue_reporting
  FROM orders_to_date AS o
  LEFT JOIN refunds_to_date AS rf
    ON rf.snapshot_date = o.snapshot_date AND rf.order_id = o.order_id
  LEFT JOIN commerce_id_match AS cm
    ON cm.snapshot_date = o.snapshot_date AND cm.commerce_customer_id = o.commerce_customer_id
  LEFT JOIN email_match AS em
    ON em.snapshot_date = o.snapshot_date AND em.email_hash = o.email_hash AND o.commerce_customer_id IS NULL
  LEFT JOIN fx_daily_rates_dataset AS fx
    ON fx.rate_date = o.order_date AND fx.from_currency = o.currency_code AND fx.to_currency = o.reporting_currency
  WHERE o.merchandise_after_discount - COALESCE(rf.refunded_merchandise_amount, 0) > 0
    AND (o.commerce_customer_id IS NOT NULL OR o.email_hash IS NOT NULL)
),
-- Base population: every in-scope customer with at least one qualifying order up to the snapshot date
customer_measures AS (
  SELECT
    q.snapshot_date,
    q.customer_key,
    datediff(q.snapshot_date, MAX(q.order_date)) AS recency_days,
    COUNT(DISTINCT CASE WHEN q.order_date > q.window_start_exclusive THEN q.order_id END) AS frequency_orders,
    ROUND(SUM(CASE WHEN q.order_date > q.window_start_exclusive THEN q.net_revenue_reporting ELSE 0 END), 2) AS monetary_net_revenue
  FROM qualifying_orders AS q
  LEFT JOIN excluded_keys AS x
    ON x.snapshot_date = q.snapshot_date AND x.customer_key = q.customer_key
  WHERE x.customer_key IS NULL
  GROUP BY q.snapshot_date, q.customer_key
),
-- Customer-count quintiles over the scoring base only (at least one qualifying order in the window)
quintile_tiles AS (
  SELECT
    m.*,
    NTILE(5) OVER (PARTITION BY m.snapshot_date ORDER BY m.recency_days DESC, m.customer_key) AS r_tile,
    NTILE(5) OVER (PARTITION BY m.snapshot_date ORDER BY m.frequency_orders ASC, m.customer_key) AS f_tile,
    NTILE(5) OVER (PARTITION BY m.snapshot_date ORDER BY m.monetary_net_revenue ASC, m.customer_key) AS m_tile
  FROM customer_measures AS m
  WHERE m.frequency_orders > 0
),
-- Tied values share one score: the quintile of the first customer in the tie
tie_adjusted AS (
  SELECT
    t.*,
    MIN(t.r_tile) OVER (PARTITION BY t.snapshot_date, t.recency_days) AS r_quintile,
    MIN(t.f_tile) OVER (PARTITION BY t.snapshot_date, t.frequency_orders) AS f_quintile,
    MIN(t.m_tile) OVER (PARTITION BY t.snapshot_date, t.monetary_net_revenue) AS m_quintile
  FROM quintile_tiles AS t
),
-- F scores 1 to 3 collapsed into one value when no customer holds F quintile 2 or 3
f_method AS (
  SELECT
    snapshot_date,
    CASE WHEN SUM(CASE WHEN f_quintile IN (2, 3) THEN 1 ELSE 0 END) = 0 THEN true ELSE false END AS use_fixed_f_cutoffs
  FROM tie_adjusted
  GROUP BY snapshot_date
),
scores AS (
  SELECT
    t.snapshot_date,
    t.customer_key,
    t.r_quintile AS r_score,
    CASE
      WHEN fm.use_fixed_f_cutoffs AND t.frequency_orders >= 6 THEN 5
      WHEN fm.use_fixed_f_cutoffs AND t.frequency_orders >= 4 THEN 4
      WHEN fm.use_fixed_f_cutoffs AND t.frequency_orders = 3 THEN 3
      WHEN fm.use_fixed_f_cutoffs AND t.frequency_orders = 2 THEN 2
      WHEN fm.use_fixed_f_cutoffs THEN 1
      ELSE t.f_quintile
    END AS f_score,
    t.m_quintile AS m_score,
    t.monetary_net_revenue,
    CASE WHEN fm.use_fixed_f_cutoffs THEN 'fixed_cutoffs_1|2|3|4-5|6+' ELSE 'quintile' END AS f_score_method
  FROM tie_adjusted AS t
  JOIN f_method AS fm
    ON fm.snapshot_date = t.snapshot_date
),
-- Default 11-segment R x F map (all 25 cells); M is used only for ranking inside a segment
segmented AS (
  SELECT
    sc.*,
    CASE
      WHEN sc.r_score = 5 AND sc.f_score IN (4, 5) THEN 'Champions'
      WHEN sc.r_score IN (3, 4) AND sc.f_score IN (4, 5) THEN 'Loyal Customers'
      WHEN sc.r_score IN (4, 5) AND sc.f_score IN (2, 3) THEN 'Potential Loyalists'
      WHEN sc.r_score = 5 AND sc.f_score = 1 THEN 'New Customers'
      WHEN sc.r_score = 4 AND sc.f_score = 1 THEN 'Promising'
      WHEN sc.r_score = 3 AND sc.f_score = 3 THEN 'Need Attention'
      WHEN sc.r_score = 3 AND sc.f_score IN (1, 2) THEN 'About to Sleep'
      WHEN sc.r_score IN (1, 2) AND sc.f_score IN (3, 4) THEN 'At Risk'
      WHEN sc.r_score IN (1, 2) AND sc.f_score = 5 THEN 'Cannot Lose Them'
      WHEN sc.r_score = 2 AND sc.f_score IN (1, 2) THEN 'Hibernating'
      WHEN sc.r_score = 1 AND sc.f_score IN (1, 2) THEN 'Lost'
    END AS rfm_segment
  FROM scores AS sc
),
ranked AS (
  SELECT
    sg.*,
    ROW_NUMBER() OVER (
      PARTITION BY sg.snapshot_date, sg.rfm_segment
      ORDER BY sg.m_score DESC, sg.monetary_net_revenue DESC, sg.customer_key
    ) AS monetary_rank_in_segment
  FROM segmented AS sg
)
SELECT
  m.snapshot_date,
  m.customer_key,
  COALESCE(rk.rfm_segment, '(not scored)') AS rfm_segment,
  rk.r_score,
  rk.f_score,
  rk.m_score,
  rk.monetary_rank_in_segment,
  rk.f_score_method,
  m.recency_days,
  m.frequency_orders,
  m.monetary_net_revenue,
  s.window_months AS scoring_window_months,
  date_add(s.window_start_exclusive, 1) AS scoring_window_start_date,
  s.map_version
FROM customer_measures AS m
JOIN snapshots AS s
  ON s.snapshot_date = m.snapshot_date
LEFT JOIN ranked AS rk
  ON rk.snapshot_date = m.snapshot_date AND rk.customer_key = m.customer_key
ORDER BY m.snapshot_date, COALESCE(rk.rfm_segment, '(not scored)'), rk.monetary_rank_in_segment, m.customer_key;

Source Data

Commerce Platform Orders (Shopify / Adobe Commerce / Salesforce Commerce Cloud) + CRM Customer Master

Dependencies

Prerequisite: List the dependencies required for this Dimension

Report Attributes

Customer Status Customer Loyalty Tier First Order Cohort Month Acquisition Channel (First Order) Primary Product Category Country Sales Channel RFM Map Version

Segment Eligibility

true

Governance

Data SensitivityInternal

Derived Dimensions

Governance

Created bydevyendarm
Created on9/15/2026
Last modified bydevyendarm
Last modified on9/18/2026
Version1.0.0
Validation stateqa passed
StatusPUBLISHED

GitHub

View related Pull Requestcatalog/dimensions/rfm-segment.yml

Community Discussion