How a WooCommerce HPOS Migration Silently Broke Order Tracking and Customer Data

· 14 min read

The client's WooCommerce store had been running fine for three years. Around 280 orders a day, a handful of 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 their customer lookup in WooCommerce analytics was returning wrong names for the right order IDs.

What Is HPOS and Why It Matters

High-Performance Order Storage (HPOS) moves WooCommerce order data out of WordPress's generic wp_posts and wp_postmeta tables into purpose-built custom tables: wp_wc_orders, wp_wc_order_addresses, wp_wc_order_operational_data, and wp_wc_orders_meta.

The performance gains are real. Stores with 50,000+ orders see dramatically faster admin loading, order searches, and reporting. The old system required roughly 40 INSERT operations per order across wp_posts and wp_postmeta. The new structure needs at most 5.

But here's the problem: any code that reads order data using WordPress post functions — get_post_meta(), get_post(), direct SQL queries against wp_postmeta — gets back empty results after HPOS is enabled. The data isn't in those tables anymore.

WooCommerce 10.x has made HPOS the default for all stores. Rollback is no longer supported once the migration completes. The safety net of synchronised legacy tables that existed during the 8.x and 9.x transition period is gone.

The Investigation

I started with the GA4 tracking drop. The store was using a well-known WooCommerce GA4 integration plugin. I checked the plugin's source code and found it was reading order totals like this:

$order_total = get_post_meta( $order_id, '_order_total', true );
$billing_email = get_post_meta( $order_id, '_billing_email', true );

With HPOS active, both calls returned empty strings. The tracking pixel fired, but with a $0.00 transaction value and no customer identifier. GA4 was recording the events, but most were being filtered out or attributed incorrectly because of the missing data.

The Klaviyo integration had a similar issue — it was hooking into woocommerce_order_status_completed but then reading order meta through WordPress post functions. Empty meta meant the flow conditions never matched.

Neither plugin threw an error. Neither logged a warning. The data just silently disappeared.

Auditing for Broken Code

I needed to find every piece of code on the site that was still using legacy post functions for order data. I ran a search across all active plugins and custom code:

grep -rn "get_post_meta.*order" wp-content/plugins/ --include="*.php" | \
  grep -v "vendor/" | grep -v "node_modules/"
grep -rn "update_post_meta.*order" wp-content/plugins/ --include="*.php" | \
  grep -v "vendor/" | grep -v "node_modules/"
grep -rn "add_post_meta.*order" wp-content/plugins/ --include="*.php" | \
  grep -v "vendor/" | grep -v "node_modules/"

This turned up hits in three plugins and two custom functions in the theme's functions.php. All were reading order meta the old way.

The Customer Lookup Corruption

The tracking issues were straightforward to diagnose. The customer lookup problem was more insidious.

WooCommerce maintains a wp_wc_customer_lookup table that maps customer IDs to order data for analytics. After the HPOS migration, I noticed the analytics dashboard was showing wrong customer names against orders. A spot check revealed the migration had incorrectly mapped some legacy WordPress user_id values to the new HPOS customer_id fields.

This is a known issue — the migration script can corrupt the customer lookup table when mapping between the old and new data structures, especially on stores with guest orders mixed with registered customer orders.

I verified the extent of the damage:

SELECT COUNT(*) FROM wp_wc_customer_lookup cl
JOIN wp_wc_orders o ON cl.customer_id = o.customer_id
WHERE cl.user_id != o.customer_id
AND o.customer_id != 0;

172 records had mismatched customer data. Not catastrophic for a store this size, but enough to make the analytics unreliable.

Verifying the Migration Integrity

WooCommerce provides CLI tools specifically for checking HPOS data integrity. I ran the verification command:

wp wc hpos verify_data --verbose

This compares order data between the legacy posts datastore and the HPOS tables, reporting any discrepancies. On this store, 14 orders showed differences — mostly in modification dates and billing information that hadn't synced correctly before the legacy tables were abandoned.

For individual orders that looked suspicious, I used the diff command:

wp wc hpos diff 58432

This gives a human-readable comparison of a single order across both storage systems, making it easy to see exactly which fields diverged.

I also checked the overall HPOS status:

wp wc hpos status

This confirmed HPOS was active, compatibility mode was off (as expected post-migration on 10.x), and showed the count of any orders still pending sync.

The Fixes

1. Replacing Legacy Post Meta Calls

For the custom code in functions.php, I updated every get_post_meta() call to use the WooCommerce CRUD API:

// Before (broken with HPOS):
$order_total = get_post_meta( $order_id, '_order_total', true );
$billing_email = get_post_meta( $order_id, '_billing_email', true );
$custom_field = get_post_meta( $order_id, '_my_custom_tracking_id', true );

// After (works with any storage backend):
$order = wc_get_order( $order_id );
if ( ! $order ) {
    return;
}

$order_total = $order->get_total();
$billing_email = $order->get_billing_email();
$custom_field = $order->get_meta( '_my_custom_tracking_id' );

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();

2. Updating or Replacing Incompatible Plugins

The GA4 tracking plugin had an HPOS-compatible update available that I'd missed because the auto-updater had been paused during the migration. After updating, conversion tracking resumed correctly.

The Klaviyo integration needed a different fix — their latest version had switched to WooCommerce's CRUD API, but the store was on an older version pinned by a compatibility note from a year ago. I tested the current version on staging, confirmed it worked with HPOS, and deployed it.

The third plugin — a niche shipping label generator — had no HPOS-compatible version. I contacted the developer. In the meantime, I wrote a small compatibility shim hooking into 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;
    }

    $order = wc_get_order( $post_id );
    if ( ! $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' ]
    );

    // Short-circuit early if this meta key isn't one we care about.
    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;
    }

    // Use the mapped CRUD getter for core fields; fall back to get_meta() for custom keys.
    if ( isset( $meta_getters[ $meta_key ] ) && is_callable( [ $order, $meta_getters[ $meta_key ] ] ) ) {
        $result = $order->{ $meta_getters[ $meta_key ] }();
    } else {
        $result = $order->get_meta( $meta_key );
    }

    // Log interceptions so we can track 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 temporary bridge — not a long-term solution. It intercepts get_post_meta() calls for specific order meta keys and redirects them through the WooCommerce CRUD layer. Core fields like _order_total and _billing_email are routed to their dedicated getter methods, while true custom meta keys fall back to $order->get_meta(). I used it only for the specific keys the shipping plugin needed, and with WP_DEBUG enabled it logs every interception so I could track when the plugin was finally updated and the shim could be removed.

3. Rebuilding the Customer Lookup Table

For the corrupted wp_wc_customer_lookup data, there isn’t a dedicated WP‑CLI tool the way there is for the product lookup tables, so I found it most reliable to truncate and regenerate:

TRUNCATE TABLE wp_wc_customer_lookup;

Then trigger a regeneration through WooCommerce's analytics:

wp wc tool run clear_template_cache
wp action-scheduler run --hooks="wc_update_customer_lookup_table" --force

After the rebuild completed, I re-ran the mismatch query. Zero discrepancies.

Pre-Migration Checklist

If you haven't migrated to HPOS yet — or if you're managing WooCommerce stores for clients who will need to — here's what I now do before every HPOS migration:

  1. Audit all plugins for post meta usage. Run the grep commands above across the entire wp-content directory. Any plugin reading order data through get_post_meta() will break.

  2. Check plugin HPOS compatibility declarations. In WooCommerce > Status > System Status, there's a section showing which plugins have declared HPOS compatibility. But a declaration is just a flag — WooCommerce does not validate whether the plugin actually uses CRUD APIs. Test on staging regardless.

  3. Run the migration on staging first. Clone the production database, run the migration, then verify with wp wc hpos verify_data --verbose. Check order counts, run a few test purchases, confirm tracking fires.

  4. Keep compatibility mode on initially. If your WooCommerce version still supports it, enable HPOS with synchronisation on. This writes to both old and new tables, giving you a rollback path. Only disable sync after you've confirmed everything works.

  5. Set up monitoring for tracking data. Compare GA4 purchase event counts against WooCommerce order counts daily for the first two weeks after migration. A sudden drop means something broke silently.

  6. Back up before and after. Full database backup before migration. Another after. The wp_posts and wp_postmeta data for orders is not automatically deleted, but you lose the ability to roll back once compatibility mode is off.

The Takeaway

HPOS is the right direction for WooCommerce. The performance improvements are substantial, and the database schema is significantly cleaner than the old EAV-style postmeta chaos. But the migration is not the seamless toggle that WooCommerce's UI suggests.

The real danger is silence. Nothing crashes. Nothing logs an error. Your tracking just quietly stops reporting accurate data, and you don't notice until your ad spend decisions are based on two weeks of garbage.

If you're running a WooCommerce store that processes real revenue, treat the HPOS migration like a server migration — not a settings toggle. Test everything on staging, audit every plugin, and monitor closely after go-live.


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 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

Get in Touch to Discuss Your Needs