← Back to KPIs

Customer Retention Rate

Also known as: Retention Rate, CRR, Repeat Customer Retention

RetentionkpiE-commerce: Customer Retention, Loyalty & LTVRetailE-commerce & SaaS, E-commerce

Customer Retention Rate measures the percentage of the customers active at the start of a period who are still buying at its end, calculated as ((customers at end of period − new customers acquired in the period) ÷ customers at start of period) × 100. It is the standard gauge of customer loyalty and of how well lifecycle, loyalty and service programmes keep the existing customer base purchasing.

Business Use Case

1️⃣ The CRM and Lifecycle Marketing team reviews the monthly rate by acquisition cohort and first-touch channel, and when a cohort falls more than 5 points below the trailing average it launches a win-back email and SMS journey for that cohort. 2️⃣ The Loyalty Programme Manager compares retention of loyalty members against non-members each quarter and uses the gap to justify tier benefits or to redesign rewards that are not lifting repeat purchase. 3️⃣ Finance and the executive team use the yearly rate with Customer Lifetime Value to forecast revenue from the existing base and to set the acquisition budget ceiling for the next planning cycle. 4️⃣ Merchandising reviews retention by first-purchased product category and promotes or bundles the categories whose buyers return most often in new-customer offers.

Business Criticality

High

Journey / Navigation Area

Monetization

Owning Function

Marketing

Scope

User

Formula

((Customers at End of Period − New Customers Acquired During Period) / Customers at Start of Period) × 100

Calculation Notes

1️⃣ A customer is an identified buyer (logged-in user_id / CRM ID) with at least one completed purchase in the period; anonymous or guest orders that cannot be tied to a customer ID are excluded from all three counts, so report the share of identified revenue alongside the rate. 2️⃣ Customers at start of period (the denominator) is the full base of customers who purchased in the prior period of equal length, not only those who were exposed to a retention campaign or visited the site. 3️⃣ New customers are those whose first-ever completed purchase falls inside the current period; use a lookback of at least 24 months of order history so returning customers are not misclassified as new. 4️⃣ Count purchases once per transaction_id: deduplicate repeated purchase hits, and exclude zero-value orders, test orders (transaction IDs prefixed 'test' and GA4 hits with traffic_type = 'internal') and orders fully cancelled or refunded by the end of the period (GA4 refund event without items; AEP commerce.backofficeOrderCancelled, or commerce.backofficeCreditMemoIssued events whose summed refund amount reaches the original order total, on the same commerce.order.purchaseID); partially refunded orders are kept. 5️⃣ Choose the period to match the category's natural purchase cycle (monthly for grocery and consumables, quarterly or yearly for apparel, electronics and home) and never compare rates across different period lengths. 6️⃣ Customers at end minus new customers can include lapsed customers who reactivated; report reactivated customers separately and use the cohort-intersection value (start-base customers who purchased again) when a strict cohort view capped at 100% is needed. 7️⃣ Value action by industry: E-commerce and Retail use a repeat purchase; subscription businesses use an active paid subscription at period end (see Subscriber Retention Rate).

Technical Details

Measure TypePercentage
Aggregation WindowMonthly, Quarterly, Yearly

Events

PlatformEvent
Google Analytics 4purchase
Adobepurchase

Data Mappings

W3 Data Layer
{
  "digitalData": {
    "transaction": {
      "transactionID": "T-100245",
      "total": {
        "basePrice": 79,
        "shipping": 5.5,
        "transactionTotal": 84.5,
        "currency": "USD"
      },
      "profile": {
        "profileInfo": {
          "profileID": "CUST-000123"
        }
      }
    },
    "user": [
      {
        "profile": [
          {
            "profileInfo": {
              "profileID": "CUST-000123",
              "loginStatus": "logged_in"
            }
          }
        ]
      }
    ]
  }
}
GA4 Data Layer
{
  "event": "purchase",
  "user_id": "CUST-000123",
  "ecommerce": {
    "transaction_id": "T-100245",
    "value": 84.5,
    "currency": "USD",
    "shipping": 5.5,
    "tax": 0,
    "items": [
      {
        "item_id": "SKU-4471",
        "item_name": "Organic Cotton Tee",
        "item_category": "Apparel",
        "price": 39.5,
        "quantity": 2
      }
    ]
  }
}
Adobe Client Data Layer
{
  "event": "purchase",
  "user": {
    "customerId": "CUST-000123",
    "loginStatus": "logged_in"
  },
  "transaction": {
    "purchaseID": "T-100245",
    "total": 84.5,
    "currency": "USD",
    "products": [
      {
        "sku": "SKU-4471",
        "name": "Organic Cotton Tee",
        "category": "Apparel",
        "price": 39.5,
        "quantity": 2
      }
    ]
  }
}
XDM Mapping
{
  "eventType": "commerce.purchases",
  "identityMap": {
    "CRMID": [
      {
        "id": "CUST-000123",
        "primary": true
      }
    ]
  },
  "commerce": {
    "purchases": {
      "value": 1
    },
    "order": {
      "purchaseID": "T-100245",
      "priceTotal": 84.5,
      "currencyCode": "USD"
    }
  },
  "productListItems": [
    {
      "SKU": "SKU-4471",
      "name": "Organic Cotton Tee",
      "quantity": 2,
      "priceTotal": 79
    }
  ]
}

SQL Queries

Google Analytics 4 (BigQuery)
-- ==========================================
-- 1. GOOGLE BIGQUERY SQL
-- ==========================================
-- Customer Retention Rate for the last complete month vs the month before it.
-- CRR = ((customers at end - new customers) / customers at start) * 100
WITH params AS (
  SELECT
    DATE_TRUNC(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH), MONTH) AS cur_start,
    LAST_DAY(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH), MONTH) AS cur_end,
    DATE_TRUNC(DATE_SUB(CURRENT_DATE(), INTERVAL 2 MONTH), MONTH) AS prev_start,
    LAST_DAY(DATE_SUB(CURRENT_DATE(), INTERVAL 2 MONTH), MONTH) AS prev_end
),
purchase_events AS (
  SELECT
    e.user_id,
    PARSE_DATE('%Y%m%d', e.event_date) AS purchase_date,
    MAX(IF(ep.key = 'transaction_id', ep.value.string_value, NULL)) AS transaction_id,
    MAX(IF(ep.key = 'value', COALESCE(ep.value.double_value, CAST(ep.value.int_value AS FLOAT64)), NULL)) AS order_value,
    MAX(IF(ep.key = 'traffic_type', ep.value.string_value, NULL)) AS traffic_type
  FROM `your-project.analytics_123456789.events_*` AS e,
    UNNEST(e.event_params) AS ep
  WHERE e.event_name = 'purchase'
    AND e.user_id IS NOT NULL
    AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 26 MONTH))
                          AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
  GROUP BY e.user_id, e.event_date, e.event_timestamp, e.user_pseudo_id
),
refunds AS (
  -- transactions fully refunded or cancelled up to the end of the current period
  -- (GA4 full refund = refund event sent without an items array)
  SELECT DISTINCT COALESCE(r.transaction_id, r.ecommerce_transaction_id) AS transaction_id
  FROM (
    SELECT
      e.event_timestamp,
      e.user_pseudo_id,
      ANY_VALUE(e.ecommerce.transaction_id) AS ecommerce_transaction_id,
      MAX(IF(ep.key = 'transaction_id', ep.value.string_value, NULL)) AS transaction_id
    FROM `your-project.analytics_123456789.events_*` AS e,
      UNNEST(e.event_params) AS ep
    WHERE e.event_name = 'refund'
      AND ARRAY_LENGTH(e.items) = 0
      AND _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 26 MONTH))
                            AND FORMAT_DATE('%Y%m%d', LAST_DAY(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH), MONTH))
    GROUP BY e.event_timestamp, e.user_pseudo_id
  ) AS r
  WHERE COALESCE(r.transaction_id, r.ecommerce_transaction_id) IS NOT NULL
),
orders AS (
  -- one row per transaction; drop zero-value orders, test orders (transaction_id prefixed 'test'
  -- or internal traffic) and orders fully refunded or cancelled by the end of the period
  SELECT pe.user_id, MIN(pe.purchase_date) AS purchase_date, pe.transaction_id
  FROM purchase_events AS pe
  LEFT JOIN refunds AS rf
    ON rf.transaction_id = pe.transaction_id
  WHERE pe.transaction_id IS NOT NULL
    AND pe.order_value > 0
    AND NOT STARTS_WITH(LOWER(pe.transaction_id), 'test')
    AND COALESCE(pe.traffic_type, '') != 'internal'
    AND rf.transaction_id IS NULL
  GROUP BY pe.user_id, pe.transaction_id
),
first_purchase AS (
  SELECT user_id, MIN(purchase_date) AS first_purchase_date
  FROM orders
  GROUP BY user_id
),
start_base AS (
  SELECT DISTINCT o.user_id
  FROM orders AS o, params AS p
  WHERE o.purchase_date BETWEEN p.prev_start AND p.prev_end
),
end_base AS (
  SELECT DISTINCT o.user_id
  FROM orders AS o, params AS p
  WHERE o.purchase_date BETWEEN p.cur_start AND p.cur_end
),
new_customers AS (
  SELECT f.user_id
  FROM first_purchase AS f, params AS p
  WHERE f.first_purchase_date BETWEEN p.cur_start AND p.cur_end
)
SELECT
  (SELECT cur_start FROM params) AS period_start,
  (SELECT cur_end FROM params) AS period_end,
  (SELECT COUNT(*) FROM start_base) AS customers_at_start,
  (SELECT COUNT(*) FROM end_base) AS customers_at_end,
  (SELECT COUNT(*) FROM new_customers) AS new_customers,
  (SELECT COUNT(*) FROM end_base AS e JOIN start_base AS s USING (user_id)) AS retained_from_start,
  ROUND(SAFE_DIVIDE(
    (SELECT COUNT(*) FROM end_base) - (SELECT COUNT(*) FROM new_customers),
    (SELECT COUNT(*) FROM start_base)
  ) * 100, 2) AS customer_retention_rate_pct,
  ROUND(SAFE_DIVIDE(
    (SELECT COUNT(*) FROM end_base AS e JOIN start_base AS s USING (user_id)),
    (SELECT COUNT(*) FROM start_base)
  ) * 100, 2) AS cohort_retention_rate_pct;
Adobe Experience Platform (AEP Query Service)
-- ==========================================
-- 2. ADOBE AEP QUERY SERVICE SQL
-- ==========================================
-- Customer Retention Rate for the last complete month vs the month before it.
-- CRR = ((customers at end - new customers) / customers at start) * 100
WITH params AS (
  SELECT
    add_months(trunc(current_date(), 'MM'), -1) AS cur_start,
    date_sub(trunc(current_date(), 'MM'), 1) AS cur_end,
    add_months(trunc(current_date(), 'MM'), -2) AS prev_start,
    date_sub(add_months(trunc(current_date(), 'MM'), -1), 1) AS prev_end
),
order_totals AS (
  -- original order value per purchase ID
  SELECT commerce.order.purchaseID AS purchase_id, MAX(commerce.order.priceTotal) AS order_total
  FROM commerce_experience_event_dataset
  WHERE eventType = 'commerce.purchases'
    AND commerce.order.purchaseID IS NOT NULL
    AND timestamp >= add_months(trunc(current_date(), 'MM'), -26)
  GROUP BY commerce.order.purchaseID
),
credit_memos AS (
  -- Adobe Commerce credit memos (refunds) issued up to the end of the current period, summed per order
  SELECT commerce.order.purchaseID AS purchase_id, SUM(commerce.order.priceTotal) AS refunded_total
  FROM commerce_experience_event_dataset
  WHERE eventType = 'commerce.backofficeCreditMemoIssued'
    AND commerce.order.purchaseID IS NOT NULL
    AND timestamp >= add_months(trunc(current_date(), 'MM'), -26)
    AND timestamp < trunc(current_date(), 'MM')
  GROUP BY commerce.order.purchaseID
),
cancellations AS (
  -- Adobe Commerce order cancellations up to the end of the current period (any cancellation counts as full)
  SELECT DISTINCT commerce.order.purchaseID AS purchase_id
  FROM commerce_experience_event_dataset
  WHERE eventType = 'commerce.backofficeOrderCancelled'
    AND commerce.order.purchaseID IS NOT NULL
    AND timestamp >= add_months(trunc(current_date(), 'MM'), -26)
    AND timestamp < trunc(current_date(), 'MM')
),
refunds AS (
  -- purchase IDs fully refunded (summed credit memos >= original order total) or cancelled;
  -- partially refunded orders are kept, matching the GA4 full-refund rule
  SELECT cm.purchase_id
  FROM credit_memos cm
  JOIN order_totals ot ON ot.purchase_id = cm.purchase_id
  WHERE cm.refunded_total >= ot.order_total
  UNION
  SELECT ca.purchase_id
  FROM cancellations ca
),
orders AS (
  -- one row per purchase ID; drop zero-value orders, test orders (purchaseID prefixed 'test')
  -- and orders fully refunded or cancelled by the end of the period
  SELECT
    ev.identityMap['CRMID'][0].id AS customer_id,
    ev.commerce.order.purchaseID AS purchase_id,
    MIN(CAST(ev.timestamp AS DATE)) AS purchase_date
  FROM commerce_experience_event_dataset ev
  LEFT JOIN refunds rf
    ON rf.purchase_id = ev.commerce.order.purchaseID
  WHERE ev.eventType = 'commerce.purchases'
    AND ev.commerce.purchases.value > 0
    AND ev.commerce.order.priceTotal > 0
    AND ev.commerce.order.purchaseID IS NOT NULL
    AND lower(ev.commerce.order.purchaseID) NOT LIKE 'test%'
    AND ev.identityMap['CRMID'][0].id IS NOT NULL
    AND ev.timestamp >= add_months(trunc(current_date(), 'MM'), -26)
    AND rf.purchase_id IS NULL
  GROUP BY ev.identityMap['CRMID'][0].id, ev.commerce.order.purchaseID
),
first_purchase AS (
  SELECT customer_id, MIN(purchase_date) AS first_purchase_date
  FROM orders
  GROUP BY customer_id
),
start_base AS (
  SELECT DISTINCT o.customer_id
  FROM orders o CROSS JOIN params p
  WHERE o.purchase_date BETWEEN p.prev_start AND p.prev_end
),
end_base AS (
  SELECT DISTINCT o.customer_id
  FROM orders o CROSS JOIN params p
  WHERE o.purchase_date BETWEEN p.cur_start AND p.cur_end
),
new_customers AS (
  SELECT f.customer_id
  FROM first_purchase f CROSS JOIN params p
  WHERE f.first_purchase_date BETWEEN p.cur_start AND p.cur_end
),
counts AS (
  SELECT
    (SELECT COUNT(*) FROM start_base) AS customers_at_start,
    (SELECT COUNT(*) FROM end_base) AS customers_at_end,
    (SELECT COUNT(*) FROM new_customers) AS new_customers,
    (SELECT COUNT(*) FROM end_base e JOIN start_base s ON e.customer_id = s.customer_id) AS retained_from_start
)
SELECT
  p.cur_start AS period_start,
  p.cur_end AS period_end,
  c.customers_at_start,
  c.customers_at_end,
  c.new_customers,
  c.retained_from_start,
  ROUND(CASE WHEN c.customers_at_start = 0 THEN NULL
        ELSE (c.customers_at_end - c.new_customers) * 100.0 / c.customers_at_start END, 2) AS customer_retention_rate_pct,
  ROUND(CASE WHEN c.customers_at_start = 0 THEN NULL
        ELSE c.retained_from_start * 100.0 / c.customers_at_start END, 2) AS cohort_retention_rate_pct
FROM counts c CROSS JOIN params p;

Source Data

Digital Analytics (GA4) + Commerce Order History

Dependencies

Prerequisite: List the dependencies required for this KPI

Events:

purchase

Dimensions:

customer-statusacquisition-cohortfirst-touch-channel-grouping

KPIs:

Report Attributes

Acquisition Cohort First-Touch Channel Grouping Customer Loyalty Tier First-Purchased Product Category Country

Recommended Reporting Views

Views
Executive OverviewCustomer Retention & LoyaltyCRM & Lifecycle Marketing

Segment Eligibility

true

Governance

Data SensitivityInternal

Governance

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

GitHub

View File on GitHubcatalog/kpis/customer-retention-rate.yml

Community Discussion