Skip to content
WooCommerce

WooCommerce HPOS Migration: The Real Playbook for Stores with Custom Code

Every WooCommerce HPOS migration horror story traces back to custom code that still thinks orders live in wp_posts. This guide covers the 20-minute grep audit that finds every breaking pattern, the four code rewrites you'll need (with before/after diffs), CLI sync for stores with 100k+ orders, the 10-point verification checklist, rollback paths, and CI guardrails that prevent regressions after the migration is done.

Thakur Aarti
12 min read
WooCommerce HPOS migration - database migration and server infrastructure

HPOS is not the problem. Your old custom code is.

Every WooCommerce HPOS migration horror story we’ve been called in to rescue traces back to the same root cause: a functions.php snippet from 2019, a plugin the client forgot they installed, or a custom report that runs a raw SQL join against wp_postmeta. The migration itself takes fifteen minutes on a small store and runs overnight on a large one. Finding every place your code still thinks orders live in wp_posts — that’s where the work is.

This guide is not a tour of the Advanced settings screen. WooCommerce’s own docs already do that. This is the pre-flight audit, the actual migration, the verification checklist, and the rollback paths you need when something you didn’t anticipate blows up at 9:03 AM on a Tuesday.

If you are running on WooCommerce 8.2+ with a store that has custom code, integrations, or more than about 50,000 orders, read this end to end before you touch a setting.

Why you should stop stalling on HPOS

Let’s get the pitch out of the way fast. High-Performance Order Storage moves order data out of wp_posts and wp_postmeta into four dedicated tables: wp_wc_orders, wp_wc_order_addresses, wp_wc_order_operational_data, and wp_wc_orders_meta. The result is smaller indexes, faster order queries, and a schema that doesn’t force wp_postmeta to hold every _billing_email on the internet.

The numbers are real. On stores we’ve migrated in the 200k–500k order range, admin order list load time drops from 4–8 seconds to under 800ms. Reports that timed out now complete. Backup windows shrink because wp_postmeta isn’t doing the work of a CRM.

But HPOS is no longer optional in any practical sense. New stores since WooCommerce 8.2 ship with HPOS authoritative by default. Posts-table storage is on the deprecation track. If you are reading this in 2026, you are not deciding whether to migrate — you are deciding how to do it without a weekend outage. So let’s do it properly.

Decide your migration window before you touch anything

Most guides jump straight to “enable compatibility mode.” That’s backwards. The first thing you need is a number: how long will the initial sync take on your data?

Run this in a staging copy of production:

wp eval 'echo wc_get_orders(["limit" => -1, "return" => "ids", "date_created" => ">" . (time() - 86400 * 30)])[0] ?? "none";'
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type IN ('shop_order', 'shop_order_refund');"

The second count is what matters. Here is the rough throughput we’ve measured on decently-provisioned managed hosting (Kinsta, WP Engine, Cloudways Vultr HF):

OrdersExpected sync time (CLI, batch 250)Expected sync time (Action Scheduler, default)
< 10,0002–5 minutes15–30 minutes
50,00015–30 minutes3–6 hours
250,0002–4 hoursovernight
1,000,000+12–24 hoursdon’t — use CLI

Action Scheduler defaults to one batch every minute. That’s fine for small stores and a disaster for large ones. You will use CLI. We’ll get to how.

Now pick your window. HPOS sync is safe to run on a live store — writes continue to hit both tables in compatibility mode. What you are really protecting is the final flip from “posts authoritative” to “HPOS authoritative.” That is a zero-downtime operation in theory and a five-minute checkpoint in practice because you will immediately test a dozen flows. Schedule it for a low-traffic window and warn customer support.

The 20-minute code audit (do this before anything else)

Here is the part every other guide skips: you cannot trust that your custom code is HPOS-safe until you’ve grepped for it. The HPOS Compatibility Scanner plugin is useful but it only catches active plugins and themes — not a snippet someone pasted into a must-use plugin, not a fork you maintain in a private repo, not inline code in a child theme from a previous agency.

Run this audit against your site’s codebase. If you manage the site via Git (and you should), do this locally.

# The four patterns that will absolutely break under HPOS
cd wp-content/

# 1. Direct postmeta reads for order data
grep -rn "get_post_meta.*_billing\|get_post_meta.*_shipping\|get_post_meta.*_order_" plugins/ themes/ mu-plugins/

# 2. WP_Query for shop_order
grep -rn "shop_order" plugins/ themes/ mu-plugins/ | grep -i "wp_query\|get_posts\|query_posts"

# 3. Raw SQL against wp_postmeta for order lookups
grep -rn "wp_postmeta" plugins/ themes/ mu-plugins/

# 4. post_type => shop_order in meta queries
grep -rn "'post_type'.*'shop_order'" plugins/ themes/ mu-plugins/

Every match is a candidate for rewriting. Some are safe — code that only runs in a specific context WooCommerce still fills — but treat every hit as guilty until proven innocent.

For a quick database-side check, this query surfaces the meta keys still being written as if orders were posts:

SELECT meta_key, COUNT(*) as uses
FROM wp_postmeta
WHERE post_id IN (
  SELECT ID FROM wp_posts
  WHERE post_type IN ('shop_order', 'shop_order_refund')
  LIMIT 10000
)
GROUP BY meta_key
ORDER BY uses DESC;

If you see custom meta keys here that don’t appear in a list of WooCommerce-core keys, some code is writing them via update_post_meta( $order_id, … ). That code is broken on HPOS. Find it and fix it.

The four patterns that break under HPOS (with diffs)

Here are the four code smells we rewrite on every migration. Commit these changes to a feature branch, test on staging, then merge before you flip the HPOS switch.

Pattern 1: Reading order meta

The single most common HPOS break. This code looks right and was correct for a decade.

// Before — broken under HPOS
$email = get_post_meta( $order_id, '_billing_email', true );
$custom = get_post_meta( $order_id, '_my_custom_field', true );

// After — HPOS-safe
$order = wc_get_order( $order_id );
if ( ! $order ) {
    return;
}
$email  = $order->get_billing_email();
$custom = $order->get_meta( '_my_custom_field' );

The wc_get_order() function returns a WC_Order object whether the data lives in posts or in the HPOS tables. That is the whole point of the CRUD API — it abstracts the storage. Use it.

Pattern 2: Writing order meta

// Before — broken under HPOS
update_post_meta( $order_id, '_my_custom_field', $value );

// After — HPOS-safe
$order = wc_get_order( $order_id );
$order->update_meta_data( '_my_custom_field', $value );
$order->save();

The ->save() call is mandatory. Forgetting it is the most frustrating HPOS bug you will ever chase because the data appears to be set in memory and vanishes on the next page load.

Pattern 3: Querying for orders

// Before — broken under HPOS (returns empty array)
$orders = get_posts( [
    'post_type'   => 'shop_order',
    'post_status' => 'wc-processing',
    'numberposts' => 50,
    'meta_query'  => [
        [
            'key'   => '_payment_method',
            'value' => 'stripe',
        ],
    ],
] );

// After — HPOS-safe
$orders = wc_get_orders( [
    'limit'          => 50,
    'status'         => 'processing',
    'payment_method' => 'stripe',
] );

Note that wc_get_orders() has first-class arguments for most common fields. You don’t need meta_query for things like payment_method, customer_id, billing_email, or date ranges. Check the wc_get_orders documentation in the WooCommerce developer reference before you reach for meta_query — most of what you need is already a named parameter.

Pattern 4: Reports and direct SQL

This one hurts. Custom reports that JOIN wp_postmeta three times to pull billing data don’t have a drop-in replacement. You have two options.

Option A: Use the HPOS tables directly. They are documented and safe to query read-only.

global $wpdb;

// Total revenue by payment method this month — HPOS-native
$results = $wpdb->get_results( "
    SELECT payment_method, SUM(total_amount) as total
    FROM {$wpdb->prefix}wc_orders
    WHERE status = 'wc-completed'
      AND date_created_gmt >= DATE_SUB(NOW(), INTERVAL 30 DAY)
    GROUP BY payment_method
" );

Option B: Rewrite the report using wc_get_orders() and iterate. Slower but storage-agnostic.

Our opinion: if the report runs on a cron or an admin page a few times a day, use Option B. If it runs on every request or powers a dashboard widget, use Option A and accept the HPOS schema coupling.

One more gotcha most guides miss: wp_wc_orders_meta does not behave identically to wp_postmeta for LIKE queries. If you had a report searching meta_value LIKE ‘%foo%’, the index behaviour is different and you’ll want to add a functional index or refactor the query entirely.

Declare HPOS compatibility properly (for your own plugins)

If you maintain custom plugins or an MU-plugin for your site, you must declare HPOS compatibility or WooCommerce will show a warning and, depending on version, refuse to enable HPOS at all.

add_action( 'before_woocommerce_init', function() {
    if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
            'custom_order_tables',
            __FILE__,
            true
        );
    }
} );

Drop that in the main plugin file — not in an included file, not inside a class constructor, not in an init hook. It must fire on before_woocommerce_init, which happens very early. If you add this declaration you are committing to the code actually being compatible. Don’t declare a lie.

The actual migration

Now the audit is done and the code is fixed. Here is the migration itself.

Step 1. Take a full database backup. Not a snapshot of wp_postmeta — a full backup with the ability to restore wp_posts, wp_postmeta, wp_wc_orders, wp_wc_order_addresses, wp_wc_order_operational_data, and wp_wc_orders_meta together. Test the restore on a staging copy if you have the time.

Step 2. Enable compatibility mode in WooCommerce → Settings → Advanced → Features. Check “Enable compatibility mode (synchronizes orders to the posts table).” Save.

Step 3. For small stores, let Action Scheduler run its course. Check progress:

wp action-scheduler run --group=wc_orders_table_background_updates --batches=5

For large stores, skip Action Scheduler and use the CLI sync directly:

# Tune batch size to your server — start with 250
wp wc hpos sync --batch-size=250

# For very large stores, run in screen/tmux and bump the batch
wp wc hpos sync --batch-size=500

Batch size is the knob that matters. Too small and you churn through locks; too large and you OOM PHP. On a 4GB PHP worker we run 500. On smaller boxes, 250. If you see Allowed memory size errors, halve it.

Step 4. Verify sync completed. Zero pending is what you want:

wp db query "SELECT COUNT(*) FROM wp_wc_orders;"
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type IN ('shop_order', 'shop_order_refund');"

These two numbers should match. If they don’t, you have orphaned orders in one table or the other. Stop and investigate before proceeding.

Step 5. Flip authoritative. Go back to WooCommerce → Settings → Advanced → Features and select “Use the WooCommerce orders tables” (the High-Performance Order Storage option). Leave compatibility sync ON for now. Save.

You are now running HPOS-authoritative. That’s the migration. Congratulations.

The verification checklist (run through this immediately)

Do not consider the migration done until you have personally verified every item on this list. The flip is reversible for about a week of compatibility-sync history. After that, rollback gets painful.

  1. Place a test order as a guest. Complete checkout. Confirm it appears in the order list, has the correct line items, totals, and shipping.
  2. Place a test order as a logged-in customer. Confirm it appears in My Account → Orders.
  3. Manually refund a portion of a test order. Confirm the refund shows in the order notes and the refund total is correct.
  4. Change an order status manually from Processing to Completed. Confirm the transactional email sends.
  5. Run any custom cron jobs that touch orders. Watch the logs.
  6. Load your most complex report or dashboard. Compare totals to the pre-migration values.
  7. Trigger your most important third-party integration (Stripe refund, Klaviyo sync, ShipStation export, QuickBooks push). Verify data lands correctly.
  8. Hit the REST API: GET /wp-json/wc/v3/orders?per_page=5. Sanity-check the response.
  9. Load the admin order list. It should be noticeably faster.
  10. Pull up an order from six months ago. Confirm historical data is intact.

If all ten pass, you are done. If any fail, keep compatibility sync ON and diagnose before turning it off.

When and how to roll back

We’ve rolled back exactly twice in dozens of migrations, both times because of third-party plugins we didn’t have source access to that turned out to be storing data in _order_ meta keys without using the CRUD API. Here is how rollback works.

Partial rollback (easiest): Flip authoritative back to “Use the WordPress posts tables.” Because compatibility sync was ON, both tables are current. This is instant and safe. The HPOS tables still exist and still sync — you are just choosing which one WooCommerce reads from authoritatively.

Full rollback: Turn off HPOS entirely, truncate the HPOS tables, and rely on wp_posts. Only do this if you believe data in the HPOS tables has become corrupted. It’s destructive. Back up first.

The partial rollback is why you keep compatibility sync ON for at least a week after the flip. Turning it off is the real point of no return. Don’t rush it.

Staying compatible — CI guardrails

The migration is a one-time event. Staying HPOS-compatible is a forever job. Add these checks to your CI pipeline or pre-commit hook so a junior dev’s get_post_meta( $order_id, ‘_billing_email’, true ) never makes it into production.

#!/usr/bin/env bash
# ci/check-hpos.sh — fail the build if forbidden patterns appear
set -e

FORBIDDEN=(
  "get_post_meta.*_billing"
  "get_post_meta.*_shipping"
  "update_post_meta.*_order_"
  "'post_type'.*=>.*'shop_order'"
)

for pattern in "${FORBIDDEN[@]}"; do
  if grep -rEn "$pattern" wp-content/plugins/your-plugin wp-content/themes/your-theme; then
    echo "HPOS incompatibility: $pattern"
    exit 1
  fi
done

echo "HPOS compatibility check passed."

This catches 90% of regressions. It won’t catch everything — dynamic method calls and string-constructed meta keys slip through — but it’s the cheapest insurance you can buy.

For teams with a proper test suite, go further: write a PHPUnit test that boots WooCommerce with HPOS authoritative, runs every custom order hook you’ve registered, and asserts the meta round-trips correctly. We do this for every WooCommerce client we maintain and it has caught at least three regressions that the grep pass missed. If you don’t have the bandwidth for full integration tests, even a smoke test that creates an order, adds meta, reloads it, and asserts equality is enough to catch the worst class of bug — the one where ->update_meta_data() is called without ->save().

One more forever-job item: when you upgrade WooCommerce, read the release notes for HPOS-related changes. The schema is not frozen. Columns have been added, indexes have shifted, and a minor version can change query planner behaviour on very large tables. Your monitoring should include a simple query that checks average order-list load time on the admin side. If it jumps by 30% after a Woo update, that’s your signal to investigate before customers notice.

What you should actually do

Back up your database. Audit custom code with the grep patterns above. Fix the four patterns. Declare compatibility in your own plugins. Run the CLI sync. Verify counts match. Flip authoritative. Work the verification checklist. Leave compatibility sync ON for a week. Add the CI guardrail. Then turn sync off and close the ticket.

If your store has more than 100,000 orders, custom checkout logic, or third-party integrations you don’t fully control, the “fix the four patterns” step is where a weekend can disappear. Need something custom built for your WooCommerce store? Our WooCommerce Development & Customization service covers everything from custom product types to complex checkout flows — including HPOS audits and migrations for stores where a failed flip is not an option.

For related reading on WooCommerce at scale, see our guides on scaling WooCommerce to 10,000+ products and the WordPress hooks and filters patterns that keep custom code maintainable.

Leave a Reply

Your email address will not be published. Required fields are marked *