how-to · · 8 min read
woocommerce repeat purchase rate: how to measure it properly
Measure your WooCommerce repeat purchase rate as a cohort: who came back within 90 days, why the all-time number misleads, and a read-only script.
By Big Bad Agency
“What’s our repeat purchase rate?” sounds like a question with one answer. Ask three tools and you’ll get three numbers, and all three can be right, because they’re measuring different things.
The version worth tracking answers one plain question: of the people who bought from us for the first time, how many came back? Here’s how to measure that properly in WooCommerce, why the easy number misleads, and a read-only script that does it.
the easy number, and why it flatters you
The simplest repeat purchase rate is:
customers with two or more orders ÷ all customers
You can get it from WooCommerce in a couple of minutes. Go to Analytics → Customers and add the advanced filter No. of Orders, set to More Than 0, and note how many customers it shows. Change the filter to more than 1 and note that count too. The second number divided by the first is your all-time rate. (Skip the first filter and accounts that registered but never bought are counted as customers who didn’t come back.)
It’s a fine number to know once. As something to track, it has two problems:
- It mixes every customer you’ve ever had. Someone who first bought four years ago has had four years to come back. Someone who first bought last Tuesday has had a week. The number blends them together, so it can’t tell you whether customers you’re winning now come back more or less than they used to.
- Old loyal customers prop it up. A store whose recent customers have stopped returning can keep a healthy-looking all-time rate for a long time, carried by people who signed up years ago.
the number to track: a cohort rate
Fix both problems by asking the question about one group at a time:
Of the customers whose first paid order was in January, how many placed a second paid order within 90 days of their first?
That’s a cohort repeat rate. Three choices go into it:
- The cohort. Customers grouped by when they first bought. A month is the usual size. Smaller stores may need a quarter to get enough people for the number to settle.
- The window. How long after the first order a second one still counts. Pick it from how often people need what you sell: 30 days for coffee, 90 for most consumables, 180 or a year for clothes or gifts. Then keep it. Changing the window changes the number more than anything your customers do.
- Leave out anyone whose window hasn’t closed. A customer who first bought ten days ago and hasn’t ordered again isn’t a non-returner yet. Counting them drags the rate down, and it makes the most recent cohort look like a collapse every single month.
The third one is the mistake most homemade spreadsheets make. It’s also why the latest month should be blank in your report, not low.
the same decisions as any customer report
A repeat rate rests on the same three questions as splitting orders into new and returning customers:
- What counts as one customer? An account is easy. Guests are matched by billing email, and a guest who later created an account with the same email is still one person.
- Which orders count? Paid ones: processing and completed. Cancelled, failed and pending orders aren’t purchases. A fully refunded order has the Refunded status, so it isn’t counted either, which is usually what you want: a first purchase that went straight back isn’t a customer to retain.
- What’s the first order? The customer’s earliest paid order, ever, not the earliest one in the period you’re looking at. Otherwise a long-time customer looks brand new each time you pick a fresh date range.
a script that does it
This read-only WP-CLI script prints the cohort rate for any date range and window, the all-time share for comparison, and the median number of days between first and second order. It uses wc_get_orders(), so it works whether your store uses HPOS or the older post storage.
Save it as repeat-purchase-rate.php and run it from your WordPress folder. This asks about customers who first bought in the first quarter, with a 90-day window:
wp eval-file repeat-purchase-rate.php 2026-01-01 2026-03-31 90
<?php
/**
* WooCommerce repeat purchase rate, measured as a cohort.
*
* Of the customers whose FIRST paid order fell between two dates, how many placed
* a second paid order within N days of it? Also prints the all-time share of
* customers with two or more paid orders, and the median days to the second order.
*
* Run from the WordPress root with WP-CLI:
* wp eval-file repeat-purchase-rate.php 2026-01-01 2026-03-31 90
*
* Read-only. Uses wc_get_orders(), so it works with HPOS and with legacy post
* storage alike. Reads every paid order once, 200 at a time.
*/
/**
* The maths, kept free of WordPress so it can be tested on its own.
*
* @param array $orders List of array( customer_key, unix_timestamp ), any order.
* @param int $from_ts Cohort start (inclusive).
* @param int $to_ts Cohort end (inclusive).
* @param int $window Seconds after the first order that a second order still counts.
* @param int $now Current time. Customers whose window has not closed yet are left out.
*/
function bb_rpr_compute( array $orders, $from_ts, $to_ts, $window, $now ) {
$times = array();
foreach ( $orders as $o ) {
$times[ $o[0] ][] = (int) $o[1];
}
$cohort = 0;
$repeaters = 0;
$too_recent = 0;
$gaps = array();
$ever_twice = 0;
foreach ( $times as $ts ) {
sort( $ts );
if ( count( $ts ) > 1 ) {
$ever_twice++;
}
$first = $ts[0];
if ( $first < $from_ts || $first > $to_ts ) {
continue;
}
if ( $first + $window > $now ) {
$too_recent++; // Their window is still open: counting them would drag the rate down.
continue;
}
$cohort++;
if ( isset( $ts[1] ) && $ts[1] <= $first + $window ) {
$repeaters++;
$gaps[] = ( $ts[1] - $first ) / 86400.0;
}
}
$median = null;
if ( $gaps ) {
sort( $gaps );
$mid = intdiv( count( $gaps ), 2 );
$median = count( $gaps ) % 2 ? $gaps[ $mid ] : ( $gaps[ $mid - 1 ] + $gaps[ $mid ] ) / 2;
}
return array(
'customers' => count( $times ),
'ever_twice' => $ever_twice,
'cohort' => $cohort,
'repeaters' => $repeaters,
'too_recent' => $too_recent,
'rate' => $cohort ? $repeaters / $cohort : null,
'median_gap_days' => $median,
);
}
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return; // Loaded by the test, or by mistake: only the function above is defined.
}
list( $from, $to, $days ) = array_pad( $args, 3, '' );
if ( ! $from || ! $to ) {
WP_CLI::error( 'Usage: wp eval-file repeat-purchase-rate.php 2026-01-01 2026-03-31 90' );
}
$days = $days ? (int) $days : 90;
$tz = wp_timezone();
$from_ts = ( new DateTimeImmutable( $from . ' 00:00:00', $tz ) )->getTimestamp();
$to_ts = ( new DateTimeImmutable( $to . ' 23:59:59', $tz ) )->getTimestamp();
// Pass 1: every paid order, as (account id, billing email, time).
$rows = array();
$page = 1;
do {
$result = wc_get_orders(
array(
'status' => wc_get_is_paid_statuses(),
'type' => 'shop_order',
'orderby' => 'date',
'order' => 'ASC',
'limit' => 200,
'page' => $page,
'paginate' => true,
)
);
foreach ( $result->orders as $order ) {
$email = strtolower( trim( $order->get_billing_email() ) );
$rows[] = array(
(int) $order->get_customer_id(),
is_email( $email ) ? $email : '',
$order->get_date_created()->getTimestamp(),
);
}
$page++;
} while ( $page <= $result->max_num_pages );
// Pass 2: one key per person. An account is the person; a guest order whose billing
// email was ever used on an account belongs to that account; other guests are keyed
// by email; an order with neither is left out.
$email_owner = array();
foreach ( $rows as $r ) {
if ( $r[0] > 0 && '' !== $r[1] && ! isset( $email_owner[ $r[1] ] ) ) {
$email_owner[ $r[1] ] = 'user:' . $r[0];
}
}
$orders = array();
$unidentified = 0;
foreach ( $rows as $r ) {
if ( $r[0] > 0 ) {
$key = 'user:' . $r[0];
} elseif ( '' !== $r[1] ) {
$key = $email_owner[ $r[1] ] ?? 'email:' . $r[1];
} else {
$unidentified++;
continue;
}
$orders[] = array( $key, $r[2] );
}
$s = bb_rpr_compute( $orders, $from_ts, $to_ts, $days * 86400, time() );
WP_CLI::log( sprintf( 'Paid orders read: %d (%d with no account or usable email, left out)', count( $rows ), $unidentified ) );
WP_CLI::log( sprintf( 'Customers with a paid order: %d, of whom %d have two or more', $s['customers'], $s['ever_twice'] ) );
WP_CLI::log( sprintf( 'First-time customers %s to %s: %d (plus %d whose %d days are not up yet)', $from, $to, $s['cohort'], $s['too_recent'], $days ) );
if ( null === $s['rate'] ) {
WP_CLI::log( 'No first-time customers in that range with a complete window.' );
return;
}
WP_CLI::log( sprintf( 'Ordered again within %d days: %d = %.1f%%', $days, $s['repeaters'], 100 * $s['rate'] ) );
if ( null !== $s['median_gap_days'] ) {
WP_CLI::log( sprintf( 'Median days to the second order: %.0f', $s['median_gap_days'] ) );
}
The choices it makes, so you can change them on purpose:
- One customer = an account. A guest order joins the account that ever used the same billing email (lower-cased). Other guests are matched by email, and an order with neither an account nor a usable email is counted in the first line and left out of everything else.
- Paid means processing or completed, WooCommerce’s own list. A plugin can change it through the
woocommerce_order_is_paid_statusesfilter, and the script follows. - The first order is the earliest paid order ever, not the earliest in your date range. A customer who first bought in December and again in January is not in a January cohort.
- A second order exactly on the last day of the window counts. One a second later doesn’t.
- Customers whose window is still open are left out and reported separately, so the latest cohort reads as unfinished instead of terrible.
- The dates are in your store’s time zone.
We tested the maths with sample orders (cohort edges, the window edge, open windows, out-of-order input, the median). We have not run the order query on a live store, so try it on a staging copy first. It reads every paid order you’ve ever had, which on a store with hundreds of thousands of orders takes a while and some memory. Run it off-peak.
reading the number
There’s no honest universal benchmark for this. A store that sells a refillable product and a store that sells wedding dresses should have wildly different rates, and an “average repeat purchase rate” that blends them describes neither. The comparison that means something is your own cohorts, side by side:
- Run the same window for several cohorts in a row. January, February and March first-timers, each at 90 days. If each month is lower than the one before, the customers you’re winning lately are coming back less. That’s a different problem from “fewer customers”.
- Look at the median days to the second order. If most repeat buyers come back around day 40, a “we miss you” email on day 60 is late for most of them. Time your reminders and refill emails off this number, not a guess.
- Compare cohorts that came in different ways. A month with a big discount campaign often brings in people who bought once for the price. If that cohort’s repeat rate is well below a normal month’s, the campaign bought orders, not customers.
- Seasonal stores: compare a cohort to the same month last year.
- Subscriptions: renewal orders are ordinary paid orders to WooCommerce, so every subscriber counts as a repeat buyer. If the question is “who chose to come back”, exclude subscription products or renewals first.
where this fits with the rest of the numbers
The repeat rate tells you whether customers come back. New vs returning customers tells you who made a given month: whether this month’s revenue came from new people or regulars. Run the new-vs-returning split monthly, and this one quarterly, and the two together answer most “is the store healthy?” questions without a dashboard.
And if what you sell has a natural reorder point, the median days to second order is the most useful number in this post. It tells you when to ask.