How I migrated a 40,000-order WooCommerce store to HPOS — and what silently breaks when it goes wrong

· 17 min read

Last month a client running a UK pet supplies store asked me why WooCommerce kept showing a banner about "High-Performance Order Storage." They had 40,000 orders, a dozen plugins, and custom code hooking into order data. They wanted to know if they should switch.

The short answer: yes, you have to. HPOS is now the default for new WooCommerce installations and the old wp_posts-based order storage is being phased out. Every WooCommerce update pushes harder towards migration. The question isn't whether to migrate — it's how to do it without breaking your store.

Here's exactly how I handled it — and, further down, what happened on a different store where the migration was done as a one-click toggle and quietly broke conversion tracking and customer data for two weeks before anyone noticed.

Understanding what HPOS actually changes

WooCommerce has historically stored orders as a WordPress custom post type. Every order was a row in wp_posts, and every piece of order metadata — billing address, shipping method, payment tokens — lived in wp_postmeta. This worked, but it meant order queries competed with regular post queries, and the wp_postmeta table became enormous on busy stores. The old system required roughly 40 INSERT operations per order across wp_posts and wp_postmeta.

HPOS moves order data into dedicated tables: wp_wc_orders, wp_wc_orders_meta, wp_wc_order_addresses, and others. The schema is purpose-built for ecommerce — billing email gets its own indexed column instead of being buried in a key-value meta table, and a new order needs at most 5 inserts instead of 40.

The performance difference on large stores is real. On this client's site, the WooCommerce orders admin page went from a 4-second load to under 800ms after migration.

The flip side: any code that reads order data using WordPress post functions — get_post_meta(), get_post(), direct SQL against wp_postmeta — gets back empty results once HPOS is the authoritative datastore. The data isn't in those tables anymore. Nothing errors. It just returns nothing.

Step 1: Audit every plugin for compatibility

Before touching anything, I needed to know which plugins would break. WooCommerce has a built-in compatibility checker — go to WooCommerce > Settings > Advanced > Features and look at the HPOS option. If it's greyed out, click "View and manage" to see which plugins have declared themselves incompatible.

But here's the thing: not declaring compatibility isn't the same as being incompatible. Many plugins simply haven't added the compatibility flag yet but work fine with HPOS. And some plugins that declare compatibility still break in edge cases — the declaration is just a flag, WooCommerce doesn't validate whether the plugin actually uses the CRUD APIs.

I ran a more thorough check. First, I searched the codebase for any direct post meta access on orders:

grep -r "get_post_meta\|update_post_meta\|delete_post_meta" wp-content/plugins/ \
  --include="*.php" -l | sort

Then I looked specifically for raw SQL queries hitting the posts table for order data:

grep -rn "wp_posts.*shop_order\|wp_postmeta.*_order_\|wp_postmeta.*_billing_\|wp_postmeta.*_shipping_" \
  wp-content/plugins/ --include="*.php" | head -50

This turned up three problems:

  1. A custom reporting plugin querying wp_postmeta directly for order totals
  2. A shipping integration using get_post_meta() to read tracking numbers
  3. Custom theme code in functions.php using get_post_meta() to display order info on a "My Account" page

Step 2: Fix the custom code

The fix pattern is the same every time. Replace direct post meta calls with WooCommerce CRUD methods:

// Before (breaks with HPOS)
$tracking = get_post_meta($order_id, '_tracking_number', true);
$email = get_post_meta($order_id, '_billing_email', true);

// After (works with both storage backends)
$order = wc_get_order($order_id);
$tracking = $order->get_meta('_tracking_number');
$email = $order->get_billing_email();

A critical detail: for core order fields like billing name, email, and address, you must use the dedicated getter methods — not $order->get_meta('_billing_first_name'). Under HPOS, those internal meta keys are stored as columns in wp_wc_orders and wp_wc_order_addresses, not in the meta table. The getter methods know where to look. The meta method does not.

// Still broken with HPOS:
$name = $order->get_meta( '_billing_first_name' );

// Correct:
$name = $order->get_billing_first_name();

For the reporting plugin, the raw SQL queries needed rewriting to use wc_get_orders():

// Before (direct DB query)
global $wpdb;
$results = $wpdb->get_results("
    SELECT p.ID, pm.meta_value as total
    FROM {$wpdb->posts} p
    JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
    WHERE p.post_type = 'shop_order'
    AND pm.meta_key = '_order_total'
    AND p.post_date >= '2025-01-01'
");

// After (WooCommerce CRUD, paginated to avoid N+1 and memory issues)
$page     = 1;
$per_page = 500;

do {
    $orders = wc_get_orders( [
        'date_created' => '>=2025-01-01',
        'limit'        => $per_page,
        'page'         => $page,
        'return'       => 'objects',
    ] );

    foreach ( $orders as $order ) {
        $total = $order->get_total();
        // Do something with $total...
    }

    $page++;
} while ( count( $orders ) === $per_page );

For the shipping plugin, I contacted the developer. They'd already released an HPOS-compatible update two months prior — the client just hadn't updated.

Step 3: Stage and migrate

I never migrate on production first. The process:

  1. Full backup — database and files. I use UpdraftPlus to S3, plus a manual mysqldump for belt and braces.

  2. Clone to staging — exact replica of production, same PHP version, same server config.

  3. Enable compatibility mode — this is the crucial step. In WooCommerce settings, enable "High-Performance Order Storage" and also enable "Enable compatibility mode." This tells WooCommerce to keep both datastores in sync while you verify everything works.

  4. Run the sync via WP-CLI — the admin UI sync is painfully slow on large stores. WP-CLI is dramatically faster:

wp wc hpos sync --batch-size=500

For 40,000 orders this took about 25 minutes. On stores with hundreds of thousands of orders, expect hours or days.

  1. Monitor progress — check how many orders are still pending:
wp wc hpos count_unmigrated

Step 4: Verify data integrity

This is the step most guides skip, and it's the most important one. WooCommerce provides a verification command:

wp wc hpos verify_cot_data --verbose

On the newer WooCommerce versions, the equivalent command is:

wp wc hpos verify_data --verbose

This compares every order across both datastores and flags discrepancies. On this client's store, it found 12 orders with mismatched metadata — all caused by the old shipping plugin writing directly to wp_postmeta after the sync had already run.

To fix the mismatched orders, I re-synced just those specific orders:

wp wc hpos verify_data --re-migrate

Then verified again. Zero discrepancies.

Two more commands worth knowing. For a single suspicious order, wp wc hpos diff <order_id> gives a human-readable comparison of that order across both storage systems, making it easy to see exactly which fields diverged. And wp wc hpos status confirms whether HPOS is active, whether compatibility mode is on, and how many orders are still pending sync.

I also ran a manual spot check — picked 20 orders at random and compared the data in the admin UI against a direct database query on the new wp_wc_orders table:

SELECT id, billing_email, total_amount, date_created_gmt
FROM wp_wc_orders
WHERE id IN (12345, 12346, 12347)
ORDER BY id;

Everything matched.

Step 5: Test everything that touches orders

With compatibility mode still on, I tested every order-related workflow:

  • New order placement (guest and logged-in)
  • Payment processing (Stripe and PayPal)
  • Subscription renewals (WooCommerce Subscriptions)
  • Refund processing
  • Order status emails
  • Shipping label generation
  • The custom reporting page
  • WooCommerce REST API order endpoints
  • CSV order exports

Two issues surfaced. The custom reporting page was still using a cached query that bypassed the CRUD layer — I'd missed one function. And the CSV export plugin needed updating to its latest version.

Step 6: Go live and disable compatibility mode

Once staging was clean, I repeated the entire process on production:

  1. Enabled HPOS + compatibility mode
  2. Ran wp wc hpos sync during a quiet period (Sunday evening)
  3. Verified with wp wc hpos verify_data --verbose
  4. Tested all critical workflows
  5. Monitored for 48 hours with both datastores in sync

After two days of clean operation, I disabled compatibility mode. This makes the HPOS tables the sole authoritative datastore. The old wp_posts and wp_postmeta order data is still there but no longer written to.

What silently breaks when you skip the audit

That's the migration done properly. Here's what it looks like when it isn't — a separate store I was brought in to fix, months apart from the migration above.

This store had been running fine for three years — around 280 orders a day, subscription renewals, GA4 conversion tracking feeding their ad spend decisions. Then they updated to WooCommerce 10.2 and accepted the HPOS migration prompt. No errors. No warnings. The dashboard looked normal. Orders kept coming in.

Two weeks later, the client noticed their GA4 purchase events had dropped by roughly 60%. Their Klaviyo post-purchase email flows had stopped triggering for most orders. And WooCommerce analytics was returning wrong customer names for the right order IDs.

The GA4 integration plugin was reading order totals with get_post_meta( $order_id, '_order_total', true ). With HPOS active, that returns an empty string. The tracking pixel still fired — but with a zero transaction value and no customer identifier, so GA4 filtered or misattributed most of the events. The Klaviyo integration had the same disease: it read order meta through WordPress post functions, so its flow conditions never matched. Neither plugin threw an error. Neither logged a warning. That's the real danger with HPOS breakage — nothing crashes, your tracking just quietly stops reporting accurate data until your ad spend decisions are based on two weeks of garbage.

The fixes:

Update the plugins first. The GA4 plugin had an HPOS-compatible update available that had been missed because auto-updates were paused during the migration. Klaviyo's current version had already switched to the CRUD API — the store was pinned to an old version. Testing and updating both restored tracking.

Shim what can't be updated. A niche shipping label generator had no HPOS-compatible version at all. While waiting on the developer, I wrote a temporary bridge hooking get_post_metadata to intercept the broken meta calls and redirect them through WooCommerce's CRUD layer:

add_filter( 'get_post_metadata', function( $value, $post_id, $meta_key, $single ) {
    if ( ! function_exists( 'wc_get_order' ) ) {
        return $value;
    }

    // Map core meta keys to their CRUD getters — get_meta() won't work for these under HPOS.
    $meta_getters = [
        '_order_total'   => 'get_total',
        '_billing_email' => 'get_billing_email',
    ];

    // Only intercept known order meta keys (core fields + specific custom keys).
    $order_meta_keys = array_merge( array_keys( $meta_getters ), [ '_shipping_method' ] );

    if ( empty( $meta_key ) || ! in_array( $meta_key, $order_meta_keys, true ) ) {
        return $value;
    }

    // Only handle actual order posts.
    $post_type = get_post_type( $post_id );
    if ( 'shop_order' !== $post_type && 'shop_order_refund' !== $post_type ) {
        return $value;
    }

    $order = wc_get_order( $post_id );
    if ( ! $order ) {
        return $value;
    }

    // Core fields go to their dedicated getters; custom keys fall back to get_meta().
    if ( isset( $meta_getters[ $meta_key ] ) ) {
        $result = $order->{ $meta_getters[ $meta_key ] }();
    } else {
        $result = $order->get_meta( $meta_key );
    }

    // Log interceptions so we know when the plugin is finally updated.
    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
        error_log( sprintf( 'HPOS shim: intercepted get_post_meta for key "%s" on order #%d', $meta_key, $post_id ) );
    }

    return $single ? $result : [ $result ];
}, 10, 4 );

This is a stopgap, not a solution — I used it only for the specific keys the shipping plugin needed, and the WP_DEBUG logging let me track when it could be removed.

Rebuild the customer lookup table. The wrong-customer-names problem was the nastiest part. The migration had incorrectly mapped some legacy WordPress user_id values to the new HPOS customer_id fields in wp_wc_customer_lookup — a known issue, particularly on stores mixing guest orders with registered customer orders. A mismatch query found 172 corrupted records. There's no dedicated WP-CLI rebuild tool the way there is for product lookup tables, so the reliable fix was to truncate and regenerate:

TRUNCATE TABLE wp_wc_customer_lookup;
wp action-scheduler run --hooks="wc_update_customer_lookup_table" --force

After the rebuild, the mismatch query returned zero discrepancies. Running wp wc hpos verify_data --verbose on this store also surfaced 14 orders with differences — mostly modification dates and billing information that hadn't synced before the legacy tables were abandoned. On WooCommerce 10.x, rollback is no longer supported once the migration completes, which is exactly why the staged approach above matters: this store had no safety net.

What to watch out for

A few more gotchas I've seen across HPOS migrations:

Deactivated plugins with custom post types. If you have WooCommerce Subscriptions or Bookings deactivated during migration, their related order data can get corrupted. Make sure every order-related plugin is active before you sync.

Redis object cache interactions. If you're running Redis (and you should be on a WooCommerce store), flush the object cache after completing the migration. Stale cached order objects can cause confusing behaviour.

wp cache flush

Monitor tracking data after go-live. Compare GA4 purchase event counts against WooCommerce order counts daily for the first two weeks after migration. A sudden drop means something broke silently — you won't see errors, you'll just see fewer conversions.

Back up before and after. The wp_posts and wp_postmeta order data isn't automatically deleted, but once compatibility mode is off, you lose the ability to roll back.

Was it worth it?

For a 40,000-order store, the performance improvement was significant. Admin order list loads dropped from 4 seconds to under a second. Order search became near-instant. The wp_postmeta table — previously the biggest table in the database at 2.1 million rows — is no longer involved in order queries at all.

More importantly, HPOS is where WooCommerce development is heading. New features are being built around it. Staying on the old posts-based storage means falling behind on updates and compatibility. The migration isn't optional anymore — it's a matter of when, not if.

But treat it like a server migration, not a settings toggle. The difference between the two stores in this post is the audit, the staging run, and the verification. If you'd rather not deal with the audit, sync, verification, and testing yourself — that's exactly what a maintenance plan covers.


Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for UK agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans → Get in Touch →

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs