Blog · Tutorial · 8 min read · May 23, 2026

How to limit a WooCommerce product to one per customer (the right way)

If you've ever tried to enforce a "one per customer" rule on a WooCommerce limited drop, you've probably discovered that WooCommerce's built-in tools look like they do this — and then someone checks out twice in five minutes and you find out they don't.

This post explains exactly where the gap is, and walks through three solutions, ranked from quick-hack to production-grade. We'll also cover the parts most guides miss: guest checkout, variations, refunds, and HPOS.

TL;DR. WooCommerce's "Maximum quantity" field is per order, not per customer. To enforce a lifetime limit you need to query the customer's prior orders (by user ID and by billing email) at every entry point: add-to-cart, cart update, and checkout. A code snippet works for one product; a plugin is what you want at three or more.

What WooCommerce actually does out of the box

Edit any product in WooCommerce and open the Inventory tab. You'll see a field called Sold individually. Toggling it limits a customer to one of that product per order. That's the entire mechanism.

If you have the official Min/Max Quantities extension installed, you get a "Maximum quantity" field too. Same scope: per order.

So a determined customer who wants two of your limited-edition shirt just opens two browser tabs, or checks out once and comes back the next day, or refreshes the page and orders again. Nothing in core stops them.

Why this is a real problem (and not just a theoretical one)

Three patterns show up in nearly every drop we've helped customers run:

  1. Honest re-order. A customer thinks the first order failed, tries again, both succeed. Now you have to manually refund and apologize.
  2. "Just one more." A collector buys one, decides they want a backup, places a second order. You either let them keep both (and disappoint the next ten customers) or refund one (and make an enemy).
  3. Reseller scoop. A handful of resellers spin up multiple browser sessions, multiple emails, multiple cards, and buy 5–10 each in the first sixty seconds. By the time your real customers see the product, it's sold out.

Pattern #1 and #2 are stoppable with a per-customer lifetime limit. Pattern #3 needs more than that — we'll cover what's actually possible later.

The three solutions, ranked

Solution 1: WooCommerce "Sold individually" toggle

What it does: Caps the cart at quantity 1 for that product.

What it doesn't do: Stop the same customer from placing a second order.

When to use it: When you genuinely only care about per-cart limits, and you're OK letting the same customer come back later for more.

Time to set up: 10 seconds.

Solution 2: A custom code snippet

You can write a snippet that hooks into woocommerce_add_to_cart_validation, queries the customer's previous orders for the same product, and blocks the add-to-cart if they've already bought it.

The basic shape looks like this:

add_filter( 'woocommerce_add_to_cart_validation', function( $passed, $product_id, $quantity ) {
    if ( ! is_user_logged_in() ) return $passed;
    $customer_id = get_current_user_id();

    $orders = wc_get_orders( array(
        'customer_id' => $customer_id,
        'status'      => array( 'completed', 'processing', 'on-hold' ),
        'limit'       => -1,
    ) );

    foreach ( $orders as $order ) {
        foreach ( $order->get_items() as $item ) {
            if ( (int) $item->get_product_id() === (int) $product_id ) {
                wc_add_notice( 'You have already purchased this limited product.', 'error' );
                return false;
            }
        }
    }
    return $passed;
}, 10, 3 );

This almost works. The problems show up when you put it in production:

  1. It doesn't catch guest customers. Guests don't have a customer_id. You need to wait until checkout and read the billing email.
  2. It runs on every product page load. limit => -1 means "every order this customer has ever placed." On a store with thousands of orders per customer, that's a slow query running on a public page.
  3. It doesn't check checkout. A customer can add to cart in another session before your snippet was deployed and then check out after.
  4. It doesn't handle variations. If you sell a variable product, the snippet only matches the parent product ID; a customer who bought "Red" can still buy "Blue."
  5. It doesn't handle HPOS. wc_get_orders() is the right API for HPOS, but a lot of snippets you'll find online use get_posts() directly, which silently breaks on HPOS-enabled stores.
  6. It has no admin bypass. When you try to manually place an order for a VIP customer, you get blocked.

When to use it: One product, one launch, you'll personally babysit it, and you're going to delete the snippet after. Fine for a hackathon, not fine for a real store.

Solution 3: A dedicated plugin

This is what we built DropLock for. The plugin version handles all six problems above and adds a few things you'd otherwise build yourself:

DropLock for WooCommerce

One toggle per product, then it does its job quietly. Works with HPOS, Block Checkout, and classic checkout. 14-day refund.

See pricing — from $49

What about guest checkout?

This is where most snippet-based solutions fall over. Guests don't have a user ID. At add-to-cart we don't know who they are. The only reliable signal is the billing email they enter at checkout.

The correct pattern is two-phase:

Important: the block must happen before the order is created. Otherwise you charge the customer's card, decrement stock, and then have to reverse it — which is worse than not blocking at all.

What about determined resellers?

If your problem is a reseller running ten browsers with ten emails and ten cards, a per-customer limit alone won't stop them. They can always create a new email. The honest answer:

No software stops a state-level adversary. Software lowers the floor.

What about variations?

Default expectation: if a customer buys "Red," they can't also buy "Blue" of the same limited product. That requires the limit to live on the parent product and the lookup to count any variation of that parent as the same product.

If you actually want per-variation limits (each color has its own quota), that's a separate use case. In DropLock, parent-level rollup is the default; per-variation limits are available in DropLock Pro 1.1.

What about refunds?

A refunded order doesn't change status by default — it stays "Completed" with a refund record attached. So if you refund a customer, they'll still be blocked from re-buying.

The simplest fix: change the order status to Cancelled after refunding. That removes it from the counted set and frees the customer's allowance.

The more "correct" fix (treating refunds as a release-of-allowance) is more nuanced — partial refunds, partial cancellations, line-item-level refunds — and we deliberately defer that to a Pro feature rather than try to guess your intent.

What about WooCommerce HPOS?

If you've enabled High-Performance Order Storage, orders no longer live in wp_posts. Any code that queries orders via get_posts() or WP_Query with post_type => 'shop_order' silently returns no results.

The fix is to use wc_get_orders() exclusively (the modern WC abstraction) and to declare HPOS compatibility in your plugin's main file via the FeaturesUtil API. If you're using snippets, audit them for direct post-type queries before flipping HPOS on a live store.

Quick checklist before your next drop

Wrapping up

The built-in WooCommerce tools cover one specific case (one per cart). Everything beyond that — lifetime limits, guest matching, variation rollup, HPOS, admin bypass, logs — you either build yourself or use a plugin built for it.

If you're running one launch, write the snippet. If you're running drops as a real part of your business, the time saved on the third one pays for the plugin.

Try DropLock on your next drop

One toggle per product. Lifetime per-customer limits. Guest checkout, HPOS, and Block Checkout supported out of the box. 14-day refund, no questions asked.

Get DropLock — from $49

Further reading