DEV Community

Armand al-farizy
Armand al-farizy

Posted on

Scanning 50,000 WooCommerce Products Without Hitting a PHP Timeout

If you've ever managed a WooCommerce store past a few hundred products, you already know the catalog rots quietly. Nothing throws an error, a product just goes live with no featured image, an import leaves two products sharing the same SKU, a variation ends up with a $0 price. You don't find out until a customer does, or your accountant does, or your inventory sync silently breaks.

I wanted to write a scanner for this, nothing fancy, just walk the whole catalog and flag the obvious stuff: missing images, empty descriptions, duplicate SKUs, zero prices, uncategorized products, stock-tracking gaps. The interesting part wasn't the checks themselves, it was making the scan not fall over on a 50,000-product catalog running on $8/month shared hosting with a 30-second PHP execution limit.

The naive version dies immediately

The obvious first pass is something like:

$products = wc_get_products( array( 'limit' => -1 ) );
foreach ( $products as $product ) {
    // check missing image, empty description, etc.
}
Enter fullscreen mode Exit fullscreen mode

limit => -1 on a large catalog means WooCommerce hydrates every single product object into memory before your loop even starts. On a few hundred products this is fine. On 50,000, you'll blow past memory_limit long before you see a timeout, and if you somehow survive that, the single request will still exceed most hosts' execution time limit.

Batch it, and don't hydrate more than you need

Two changes fixed this:

1. Paginate with limit+ page, in small batches. Instead of pulling everything at once, pull 200 products at a time and process sequentially:

$page = 1;
do {
    $products = wc_get_products( array(
        'limit' => 200,
        'page'  => $page,
        'status' => 'publish',
    ) );

    foreach ( $products as $product ) {
        $this->evaluate( $product );
    }

    $page++;
} while ( count( $products ) === 200 );
Enter fullscreen mode Exit fullscreen mode

2. Don't ask WooCommerce for full product objects when you only need a few fields. wc_get_products() is convenient but it's building full WC_Product objects with all their lazy-loaded meta. For a few of the checks (duplicate SKU detection across the entire catalog, for instance), you don't need the object, you need one column, from every row, as cheaply as possible. That one's a direct, indexed query against postmetafor _sku, grouped and counted in SQL rather than in PHP:

global $wpdb;
$duplicates = $wpdb->get_results( "
    SELECT meta_value AS sku, COUNT(*) AS cnt
    FROM {$wpdb->postmeta}
    WHERE meta_key = '_sku' AND meta_value != ''
    GROUP BY meta_value
    HAVING cnt > 1
" );
Enter fullscreen mode Exit fullscreen mode

That single query replaces what would otherwise be an O(n) loop holding every SKU in a PHP array to compare against itself, trivial on 500 products, not trivial on 50,000.

The batching also had to survive real-world messiness

Testing this against a deliberately messy seed catalog turned up a detail I hadn't planned for: WooCommerce itself refuses to save a duplicate SKU through the normal $product->set_sku()->save() API, it validates uniqueness before writing. Which is correct behavior for a live store, but it meant my "give me realistic dirty data to test against" seed script couldn't create the exact scenario the scanner exists to catch.

Real duplicate SKUs don't usually come from someone using the normal WooCommerce UI (it would block them, same as the API did here), they come from CSV imports, migrations, or direct database edits that bypass that validation layer entirely. So the seed script had to simulate that same bypass: write the duplicate SKU straight into postmeta, skipping set_sku() entirely, to reproduce what actually happens in the wild.

It's a small thing, but it's a good reminder that "realistic test data" sometimes means deliberately going around the same validation your production code relies on, because your production code has to handle data that got there the same way.

Where this ended up

Batched pagination + raw SQL for the aggregate checks got scan time on a 50k-product catalog down to something that comfortably fits inside a standard PHP execution window, without raising memory_limit or touching php.ini at all, which matters if you're building something meant to run on ordinary shared hosting, not a VPS you control.

If anyone's solved similar large-catalog scanning problems in WooCommerce or WordPress more generally, I'd like to hear how, especially around avoiding full object hydration when you only need a handful of fields.


I packaged this scanner into a small self-hosted plugin (no external API calls, no subscription) if anyone wants to poke at it, happy to share more details in the comments rather than drop a link here.

Top comments (0)