Customer Retention Rate
Also known as: Retention Rate, CRR, Repeat Customer Retention
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
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
Events
| Platform | Event |
|---|---|
| Google Analytics 4 | purchase |
| Adobe | purchase |
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
Report Attributes
Acquisition Cohort First-Touch Channel Grouping Customer Loyalty Tier First-Purchased Product Category Country
Recommended Reporting Views
Segment Eligibility
true
Governance
Governance
GitHub
catalog/kpis/customer-retention-rate.yml