High-Performance Order Storage (HPOS) moves orders out of wp_posts and wp_postmeta and into four dedicated tables: wp_wc_orders, wp_wc_orders_meta, wp_wc_order_addresses and wp_wc_order_operational_data. Core WooCommerce handles the switch cleanly. Custom plugins usually do not, because they treat an order as a post and its meta as postmeta.
This walkthrough is for developers maintaining a plugin or child theme that reads or writes order meta. You will audit the code, declare compatibility the correct way, move to WC_Order CRUD and wc_get_orders(), backfill renamed or late-written meta, and rewrite any reporting SQL that still points at wp_posts.
Prerequisites
- WooCommerce 8.2 or later (HPOS is stable and enabled by default for new installs from 8.2).
- WordPress 6.4+ with WP-CLI available on the server.
- A staging clone of production with the same plugin set — never test this on live orders.
- A database backup you have actually restored once, so you know the procedure works.
- Familiarity with get_post_meta() and WP_Query, since that is what you are replacing.
Step 1 Audit every order meta read and write
Start with a blunt text search. You are looking for four patterns: postmeta functions, get_post() on an order ID, WP_Query against shop_order, and raw SQL touching wp_posts.
cd wp-content/plugins/acme-order-extras
grep -rn --include='*.php' -E \
'get_post_meta|update_post_meta|add_post_meta|delete_post_meta|get_post\(|WP_Query|wp_posts' . \
| grep -Ei 'order|shop_order'
Then list which meta keys actually exist on your orders. Anything not prefixed with an underscore is a candidate for a custom admin screen; anything with an underscore is protected and hidden from the Custom Fields box.
SELECT pm.meta_key, COUNT(*) AS rows_count
FROM wp_postmeta pm
INNER JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.post_type = 'shop_order'
GROUP BY pm.meta_key
ORDER BY rows_count DESC
LIMIT 40;
Adjust the table prefix. Write down every key your plugin owns. Those are the keys you must guarantee survive the migration and remain queryable afterwards.
Why this matters: the HPOS migrator copies all postmeta rows for an order into wp_wc_orders_meta, including your custom keys. Data loss is rare. The breakage is in the reads — get_post_meta( $order_id, '_acme_gift_message', true ) returns an empty string once HPOS is authoritative, because there is no longer a post row to read from.
Step 2 Declare compatibility on the right hook
WooCommerce shows an admin notice and blocks HPOS activation while any active plugin has not declared support. Declare it from before_woocommerce_init — declaring later in the load order is silently ignored, which is the single most common reason a plugin still shows as incompatible.
<?php
/**
* Plugin Name: Acme Order Extras
*/
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 // true = compatible, false = explicitly blocks HPOS.
);
}
} );
Only flip that to true after Steps 3 to 5 are done. Declaring compatibility while still calling update_post_meta() on order IDs is how sites end up with orders that look correct in the admin but return blank custom fields in emails.
Step 3 Replace postmeta calls with WC_Order CRUD
The CRUD API is datastore-agnostic: the same code works in CPT mode and HPOS mode. Reads come from get_meta(), writes go through update_meta_data() followed by save(). Forgetting save() is the second most common bug — the value sits in memory and vanishes at the end of the request.
function acme_get_gift_message( WC_Order $order ): string {
// Second argument true = single value, not an array.
return (string) $order->get_meta( '_acme_gift_message', true );
}
function acme_set_gift_message( int $order_id, string $message ): void {
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return;
}
$order->update_meta_data( '_acme_gift_message', sanitize_textarea_field( $message ) );
$order->save(); // Persists to whichever datastore is active.
}
// Deleting is explicit, not an update to an empty string.
function acme_delete_gift_message( WC_Order $order ): void {
$order->delete_meta_data( '_acme_gift_message' );
$order->save();
}
Sanitise on write, escape on output. update_meta_data() does not sanitise for you, and the value will be rendered in admin order screens and transactional emails. See the sanitising guide for the correct function per data type.
If you need to know which storage mode is live — for example to run a legacy code path during a phased rollout — use OrderUtil rather than checking for a table or option directly:
use Automattic\WooCommerce\Utilities\OrderUtil;
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
// HPOS is authoritative for reads and writes.
} else {
// Still on the posts table.
}
Step 4 Replace WP_Query with wc_get_orders()
Any list of orders built with WP_Query, get_posts() or a post_type => 'shop_order' loop will return nothing under HPOS. wc_get_orders() wraps WC_Order_Query and translates your arguments for the active datastore.
$orders = wc_get_orders( array(
'limit' => 50,
'status' => array( 'wc-processing', 'wc-completed' ),
'orderby' => 'date',
'order' => 'DESC',
'meta_query' => array(
array(
'key' => '_acme_gift_message',
'compare' => 'EXISTS',
),
),
) );
foreach ( $orders as $order ) {
printf(
"#%d %s — %s\n",
$order->get_id(),
esc_html( $order->get_status() ),
esc_html( acme_get_gift_message( $order ) )
);
}
Performance caveat: meta_query against the HPOS datastore joins wp_wc_orders_meta, which is not indexed for arbitrary key/value lookups the way a purpose-built column would be. It works, but on a store with hundreds of thousands of orders it will be slow. If a meta value is central to how you filter orders, promote it to a real column in your own table, or store it in wp_wc_order_operational_data via a supported hook.
For plugins that must support both modes during a transition, the CPT datastore exposes a filter that lets custom query vars map to meta clauses. Treat it as a bridge, not a destination:
add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', function ( array $query, array $query_vars ): array {
if ( ! empty( $query_vars['acme_has_gift_message'] ) ) {
$query['meta_query'][] = array(
'key' => '_acme_gift_message',
'compare' => 'EXISTS',
);
}
return $query;
}, 10, 2 );
Step 5 Backfill renamed or late-written custom meta
Two scenarios need a backfill. First, you renamed a key (_acme_gift_msg to _acme_gift_message). Second, some code wrote to postmeta after the HPOS migration ran, so the value exists in wp_postmeta but not in wp_wc_orders_meta.
Run this with HPOS still disabled and data sync enabled, so writes land in both stores and the subsequent migration copies them across. It is idempotent — safe to re-run.
<?php
/**
* wp eval-file backfill-acme-order-meta.php
*/
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
$page = 1;
do {
$ids = get_posts( array(
'post_type' => 'shop_order',
'post_status' => 'any',
'posts_per_page' => 200,
'paged' => $page,
'fields' => 'ids',
'meta_key' => '_acme_gift_msg',
) );
foreach ( $ids as $id ) {
$order = wc_get_order( $id );
if ( ! $order instanceof WC_Order ) {
continue;
}
$legacy = (string) $order->get_meta( '_acme_gift_msg', true );
$exists = (string) $order->get_meta( '_acme_gift_message', true );
if ( '' === $legacy || '' !== $exists ) {
continue;
}
$order->update_meta_data( '_acme_gift_message', $legacy );
$order->save();
}
WP_CLI::log( sprintf( 'Page %d: %d orders scanned.', $page, count( $ids ) ) );
$page++;
} while ( count( $ids ) === 200 );
WP_CLI::success( 'Backfill complete.' );
Batch size matters. Two hundred orders per page keeps memory flat and lets you resume after a timeout. Do not wrap the whole run in a single transaction on a large store.
Step 6 Fix reports that query wp_posts directly
WooCommerce Analytics (the wc-admin reports) reads from lookup tables — wp_wc_order_stats, wp_wc_order_product_lookup, wp_wc_order_coupon_lookup — not from postmeta. Those keep working after migration. What breaks is your custom reporting SQL.
A typical pre-HPOS revenue query looks like this:
-- Breaks under HPOS: no shop_order rows in wp_posts.
SELECT p.post_status, SUM( pm.meta_value ) AS revenue
FROM wp_posts p
INNER JOIN wp_postmeta pm
ON pm.post_id = p.ID
AND pm.meta_key = '_order_total'
WHERE p.post_type = 'shop_order'
GROUP BY p.post_status;
The HPOS equivalent reads real columns. Note that status values keep the wc- prefix, and the customer moves from _customer_user to customer_id:
SELECT status, COUNT(*) AS order_count, SUM( total_amount ) AS revenue
FROM wp_wc_orders
WHERE type = 'shop_order'
AND status IN ( 'wc-processing', 'wc-completed' )
GROUP BY status
ORDER BY revenue DESC;
Address fields move too. _billing_email becomes billing_email in wp_wc_order_addresses, joined on order_id. Currency, payment method and transaction ID live in wp_wc_orders itself. Before rewriting, run DESCRIBE wp_wc_orders; on staging and map every column you touch — guessing here produces quietly wrong numbers, which is worse than a fatal error.
Where possible, skip SQL entirely and aggregate in PHP with wc_get_orders() plus $order->get_total(). It is slower per row but survives future schema changes.
Step 7 Enable HPOS and verify
Go to WooCommerce → Settings → Advanced → Features and enable High-Performance Order Storage with compatibility mode (sync) on. Then check status and run the sync from the command line:
wp wc hpos status
wp wc hpos sync
wp wc hpos verify
Command names vary slightly by WooCommerce version — run wp wc hpos --help first. After the sync completes, place a test order, add a gift message, and confirm it appears in the admin order screen, the customer email, and your custom report. Then switch off compatibility mode on staging and repeat. Only disable sync in production once a full week of orders has passed verification.
Troubleshooting
- Plugin still flagged incompatible.
declare_compatibility()is running too late, or another plugin calls it withfalse. Search the whole plugin directory fordeclare_compatibility. - Custom field returns empty after switching. Something still calls
get_post_meta(). Re-run the Step 1 grep against the live plugin set, including mu-plugins. - Meta written but not saved. You called
update_meta_data()withoutsave(), or you fetched the order withwc_get_order()inside a loop and relied on object caching. Save explicitly. - Reports show zero. Custom SQL still targets
wp_posts. Check forpost_type = 'shop_order'in query strings and stored procedures. - Meta invisible in the admin Custom Fields box. Keys starting with
_are protected by design. Do not remove the underscore to “fix” this — build a proper metabox or use the order edit screen hooks instead. - Sync stalls on a large store. Increase
WP_MEMORY_LIMITandmax_execution_time, then re-run; the synchroniser is resumable.
Next steps
- Add an index on your own lookup table if you filter orders by a custom value at scale, rather than leaning on
meta_query. - Expose the meta through the Orders REST API with a registered schema so the block-based admin and external integrations can read it.
- Add a regression test that creates an order, writes meta via CRUD, and asserts the value survives a datastore switch — this is the check that catches future regressions.
- Review the official HPOS documentation for the current list of supported query arguments before you ship.
Leave a Reply