how-to · · 8 min read
woocommerce new vs returning customers: who actually made the month
Split WooCommerce orders into new, returning and guest customers: how the built-in report decides, where it surprises you, and a read-only script.
By Big Bad Agency
It was a good month. Orders are up and revenue is up. 🎉
Then the useful question: who made it a good month?
If it was new customers, whatever brought them in worked, and the job now is getting them to come back. If it was regulars buying more often, retention is working and acquisition may be flat underneath it. Those lead to different decisions, and the revenue total can’t tell them apart.
The fix is to split the month’s orders three ways: new customers, returning customers, and orders you can’t tie to a customer at all. WooCommerce can do part of this out of the box. Here is how it decides, where it surprises people, and a short read-only script for the version you control.
the three buckets, and the choices behind them
Every new-vs-returning number rests on three decisions. Two reports that disagree almost always made different ones.
- What counts as one customer. An account is easy. A guest checkout isn’t: you can match guests by billing email, or treat every guest order as unknown.
- What “returning” means. Either “had a paid order before this one”, judged order by order, or “was already a customer before this period started”, judged once per person.
- Which orders count. Paid orders only (processing and completed), or anything not cancelled? Refunds in or out?
Write your answers down once. Then the split means the same thing every month.
option 1: woocommerce analytics (built in)
Go to Analytics → Orders. The table has a Customer type column, and under Advanced filters you can add Customer type and pick New or Returning. Run it for the month and you have the split.
How WooCommerce decides, from its source code (version 10.7):
- A customer is an account, or a billing email. A guest order is matched to an existing customer record by its billing email.
- An order is “returning” if it isn’t that customer’s oldest counted order. It looks back at all of that customer’s orders, not just the date range you picked.
- Statuses you exclude don’t count. The excluded statuses are set in Analytics → Settings. An excluded order can’t be someone’s “first order”.
Three things to know before you trust the number:
- It counts orders, not people. A brand-new customer who orders twice in the same month shows one New order and one Returning order. That’s correct by its definition, but “how many new customers did we get?” needs counting distinct customers, not filtered rows.
- Guests aren’t a separate bucket. They’re folded in by email. Someone who checks out with a different email, or a typo, is a new customer each time.
- It can only see orders that are in Analytics. On a store that has been around longer than its Analytics data, check that the historical import has run (Analytics → Settings → Import historical data). Otherwise long-time customers’ earlier orders are invisible, and they show up as new.
option 2: a script you control
If you want the split by person, with guests you can’t identify kept separate and revenue attached, this read-only WP-CLI script does it. It uses wc_get_orders(), so it works whether your store uses HPOS or the older post storage.
Save it as new-vs-returning.php and run it from your WordPress folder:
wp eval-file new-vs-returning.php 2026-09-01 2026-09-30
<?php
/**
* Split a date range's paid WooCommerce orders into new customers, returning
* customers, and orders with no identifiable customer.
*
* Run from the WordPress root with WP-CLI:
* wp eval-file new-vs-returning.php 2026-09-01 2026-09-30
*
* Read-only. Uses wc_get_orders(), so it works with HPOS and with legacy
* post storage alike.
*/
/**
* One key per customer: the account if there is one, otherwise the billing email.
*/
function bb_nvr_customer_key( WC_Order $order ) {
$user_id = $order->get_customer_id();
if ( $user_id > 0 ) {
return 'user:' . $user_id;
}
$email = strtolower( trim( $order->get_billing_email() ) );
return is_email( $email ) ? 'email:' . $email : '';
}
/**
* Did this customer have a paid order before this one?
* Checks the account first, then the billing email, so a guest who later
* registered with the same address still counts as returning.
*/
function bb_nvr_has_earlier_paid_order( WC_Order $order ) {
$base = array(
'status' => wc_get_is_paid_statuses(),
'type' => 'shop_order',
'date_created' => '<' . $order->get_date_created()->getTimestamp(),
'limit' => 1,
'return' => 'ids',
);
$user_id = $order->get_customer_id();
if ( $user_id > 0 && wc_get_orders( $base + array( 'customer_id' => $user_id ) ) ) {
return true;
}
$email = trim( $order->get_billing_email() );
if ( is_email( $email ) && wc_get_orders( $base + array( 'billing_email' => $email ) ) ) {
return true;
}
return false;
}
list( $from, $to ) = array_pad( $args, 2, '' );
if ( ! $from || ! $to ) {
WP_CLI::error( 'Usage: wp eval-file new-vs-returning.php 2026-09-01 2026-09-30' );
}
$order_ids = wc_get_orders(
array(
'status' => wc_get_is_paid_statuses(),
'type' => 'shop_order',
'date_created' => $from . '...' . $to, // Both days included, store time zone.
'orderby' => 'date',
'order' => 'ASC',
'limit' => -1,
'return' => 'ids',
)
);
$customer_class = array(); // key => 'new' | 'returning', decided by their first order in the range.
$orders = array( 'new' => 0, 'returning' => 0, 'unidentified' => 0 );
$revenue = array( 'new' => 0.0, 'returning' => 0.0, 'unidentified' => 0.0 );
foreach ( $order_ids as $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
continue;
}
$key = bb_nvr_customer_key( $order );
if ( '' === $key ) {
$class = 'unidentified';
} else {
if ( ! isset( $customer_class[ $key ] ) ) {
$customer_class[ $key ] = bb_nvr_has_earlier_paid_order( $order ) ? 'returning' : 'new';
}
$class = $customer_class[ $key ];
}
$orders[ $class ]++;
// Order total after refunds, minus the tax still kept. get_total_refunded() already
// includes refunded tax, so only the unrefunded tax comes off.
$tax_kept = (float) $order->get_total_tax() - (float) $order->get_total_tax_refunded();
$revenue[ $class ] += (float) $order->get_total() - (float) $order->get_total_refunded() - $tax_kept;
}
$people = array_count_values( $customer_class );
WP_CLI::log( sprintf( 'Paid orders %s to %s: %d', $from, $to, count( $order_ids ) ) );
foreach ( array( 'new', 'returning', 'unidentified' ) as $class ) {
WP_CLI::log(
sprintf(
'%-13s customers: %5s orders: %5d revenue ex. tax, after refunds: %s',
$class,
'unidentified' === $class ? '-' : (string) ( $people[ $class ] ?? 0 ),
$orders[ $class ],
wc_format_decimal( $revenue[ $class ], wc_get_price_decimals() )
)
);
}
The choices it makes, so you can change them on purpose:
- One customer = an account, otherwise a billing email (lower-cased). A guest order with no usable email lands in unidentified and is never guessed at.
- A customer is classified once, by their first order in the range. Someone whose first ever paid order is on the 3rd stays new all month, even if they order again on the 20th. That answers “how many new customers did we get?”, which the built-in report doesn’t.
- “Before” means any earlier paid order, matched by account and then by email. So a customer who checked out as a guest last year and registered this month counts as returning.
- Paid means processing or completed. That’s WooCommerce’s own list, and a plugin can change it through the
woocommerce_order_is_paid_statusesfilter. Cancelled, failed and pending orders aren’t counted. - Revenue is the order total after refunds, with the tax you kept taken out. Shipping charged is included.
It runs one lookup per customer, so on a store with tens of thousands of orders a month, run it off-peak.
reading the split
There is no universal “good” ratio of new to returning. A store selling one-off items and a store selling consumables should look completely different. What’s useful is the same split, month after month, in your own store:
- New up, returning flat. Acquisition worked. The test is next month: how many of this month’s new customers come back.
- Returning up, new flat. Your regulars carried the month. Good, but acquisition may be stalling under it.
- The unidentified share is growing. Guests with no usable email are customers you can’t follow. Better email capture at checkout makes every other number in this post more accurate.
- Seasonal stores should compare the same month last year, not last month.
- If you sell subscriptions, renewal orders are ordinary orders to WooCommerce and count as returning. Exclude them if the question is “who chose to come back”.
where our plugin fits (and where it stops)
Our free plugin BigBad Deep eCommerce Insights puts this split on its dashboard, so you don’t have to run anything. It shows a New vs Returning bar for the last 30 days: how many paid orders came from new customers, how many from returning customers, and how many were guest checkouts, with each share as a percentage.
What it does differently from the script above, said plainly:
- The window is fixed at the last 30 days. You can’t pick a range.
- Guests are always their own bucket. It doesn’t match guest orders by email, so a guest who orders every month never shows as returning.
- It counts orders, not people.
- It reads WooCommerce’s older order storage. If your store runs on HPOS with compatibility mode turned off, keep WooCommerce’s sync to the legacy tables switched on, as the plugin’s How It Works page says. Otherwise this bar has nothing to read.
If you want the same split by store for any date range, our sister product TeamBooks has it on its Smart Insights page. It matches customers by their WooCommerce customer ID, then by email, and keeps the orders it can’t identify separate rather than guessing.
Whichever you use, start with last month and write down the three numbers. Next month’s three numbers are the ones that tell you something.