← All insights

Building B2B catalogs on WooCommerce: 18,000-SKU lessons learned

WooCommerce was not designed for B2B catalogs at scale. We shipped one with 18,000 SKUs this year. Here is what broke and how we fixed it.

An industrial-parts distributor came to us in January looking to replace a fifteen-year-old custom catalog system with something modern, hosted in-house, with a reasonable admin experience for their content team. The catalog had 18,000 SKUs, a B2B login wall, customer-specific pricing tiers, 600 categories, and a sales team that needed to publish 2,000 new SKUs a quarter.

The conversation we had with them was not ‘WooCommerce or not WooCommerce.’ They had an internal WordPress preference and a small dev team that already knew the platform. The conversation was ‘what is going to break, and how do we plan for it.’ Eleven months later, the store is in production. Here is what we learned.

The first problem: wp_postmeta

WooCommerce stores almost every product attribute as a row in wp_postmeta. SKU, regular price, sale price, stock quantity, weight, dimensions, every custom attribute, every variation reference — all separate rows.

An 18,000-SKU catalog with twelve product variations on average means roughly 216,000 product rows. Each one drags about thirty postmeta rows. That is more than six million rows in wp_postmeta before custom fields. The admin product list query, which joins postmeta four or five times to filter by SKU and stock status, takes 8 to 12 seconds on a typical hosted database.

WooCommerce 4.0 introduced the wc_product_meta_lookup table specifically to address this. The lookup table denormalizes the most-queried product meta — SKU, price, stock — into a separate flat table with proper indexes. Enabling it dropped our admin product list to 800 milliseconds.

The catch is that the lookup table only gets populated when you save products. For an existing 18,000-product import, you need to run wc_update_product_lookup_tables() across every product. We batched it in groups of 200 with a CLI command.

The second problem: customer-specific pricing

B2B distributors do not have a single price per SKU. They have a list price, a contract price for tier-one customers, a different contract price for tier-two, and override prices on specific accounts. WooCommerce has no native support for this. The standard plugins — B2BKing, WooCommerce Memberships paired with WooCommerce Subscriptions, Wholesale Suite — each take a different approach.

We tested all three on a 5,000-SKU subset before committing. The winner, for this project, was a custom implementation. None of the off-the-shelf plugins handled customer-specific pricing combined with tier-based fallback and a contract override layer without either breaking the admin or adding three to four seconds to every cart calculation.

The custom implementation stores prices in a separate database table — not in postmeta. The schema is simple: product_id, user_role (or specific user_id), price. The cart calculation hooks into the woocommerce_product_get_price filter and looks up the right price for the current user. The lookup is one indexed query per cart line. Cart calculations took 80 milliseconds with this approach, against 2.5 seconds with the most popular off-the-shelf B2B pricing plugin.

The third problem: the catalog browse

An 18,000-product catalog has 600 categories. The default WooCommerce category browse pages — which run a WP_Query against wp_posts joined with wp_term_relationships, wp_postmeta for price, and the lookup table — were taking 2 to 3 seconds even with the lookup table active.

The fix here was Elasticsearch via ElasticPress. Once products are indexed, every category browse and filter query bypasses the database entirely. The browse pages dropped from 2.5 seconds to 180 milliseconds. Faceted filters — manufacturer, material, in stock, price range — render instantly because Elasticsearch is built for exactly this kind of multi-attribute filter.

The Elasticsearch infrastructure adds a meaningful operational layer. For a project this size it is worth it. For a smaller B2B catalog under 2,000 SKUs we would probably stick with the lookup table and skip the search index.

The fourth problem: bulk import

The sales team needed to import 2,000 new SKUs a quarter and update prices weekly. The native WooCommerce CSV importer choked on imports over about 1,000 rows on this database.

We replaced it with a WP-CLI command that streams the CSV row-by-row, batches inserts into transactions of 100, and updates the lookup table on commit. A 2,000-product import that took 90 minutes in the native importer drops to 8 minutes through the CLI. The sales team got a thin wrapper around the CLI as a button in the admin.

The fifth problem: B2B-specific features

The catalog needed quote requests, not direct checkout, for accounts over a credit threshold. It needed PO number capture at checkout. It needed shipping costs that depend on the customer’s contract, not the destination ZIP.

Each of these is a one-to-three-day customization on a healthy WooCommerce stack. None of them are deal-breakers. They are the kind of work that B2B builds always need and that off-the-shelf B2C tooling does not provide.

What we would do differently

The thing we would do differently next time is the search index decision. We hesitated on ElasticPress for the first two months and tried to make do with the lookup table alone. The site was usable but slow. Pulling the trigger on Elasticsearch earlier would have shaved a month off the project.

The thing we would not do differently is WooCommerce itself. For this client, with this team, with this content-publishing pattern — quarterly SKU adds, weekly price updates, frequent campaign launches — the WordPress admin and the WooCommerce data model worked. The customizations were where the customizations needed to be, and the platform took them gracefully. It is a working answer for B2B at this scale, even if it would not be our first recommendation for a B2C catalog twice as big.

Pick a stack. Or pick the team that ships every one of them.