All articles

WordPress 9 min read AgiCAD studio

WooCommerce HPOS Broke Your Order Query

A plugin that reads orders with WP_Query and get_post_meta returns nothing on an HPOS store, and nothing errors. Here is the diagnosis and the portable fix.

A developer with an old map pointing at an empty back room while a robot lights up the new shelves where the order boxes moved

The worst class of bug is the one that does not throw. A reporting plugin that has shipped orders to an accountant every month for four years runs, finishes, and emails a spreadsheet with zero rows in it. No fatal, no warning, no PHP notice. The query is valid. The table it reads is simply no longer where the orders live.

The symptom

Somebody enabled High-Performance Order Storage - WooCommerce calls it HPOS, the database people call it Custom Order Tables - and any code shaped like this quietly went blind:

// The query that used to work. On an HPOS store it returns an empty array.
$orders = get_posts( array(
	'post_type'      => 'shop_order',
	'post_status'    => 'wc-completed',
	'posts_per_page' => 50,
	'meta_key'       => '_billing_email',
	'meta_value'     => '[email protected]',
) );

foreach ( $orders as $post ) {
	$email = get_post_meta( $post->ID, '_billing_email', true );
	$total = get_post_meta( $post->ID, '_order_total', true );
	echo esc_html( $post->ID . ' ' . $email . ' ' . $total );
}

This is a perfectly well-formed WordPress query. It asks wp_posts for rows with post_type = 'shop_order'. On an HPOS store there are none, so it truthfully answers "none", and every loop over it does nothing. That is a wrong-but-plausible empty result set, and it is worse than a crash, because a crash gets reported the same day.

What HPOS actually moved

Before HPOS, an order was a post. It lived in wp_posts with a post type of shop_order, and every property - billing email, total, payment method, your own custom fields - was a row in wp_postmeta. That is a generic content table doing the work of an order ledger, and on a busy store it is exactly as fast as that sounds.

HPOS moves orders into purpose-built tables: wp_wc_orders plus companions for addresses, operational data and order-level meta. Indexed columns replace meta lookups. Order queries stop competing with posts for the same index.

The important part for debugging is that a store can be in one of three states, and only one of them breaks loudly:

  • Legacy. Orders in wp_posts. Old code works. Nothing to do yet.
  • HPOS with compatibility sync. Both stores are written, one is authoritative. Old code often appears to work, which is why a plugin can pass a staging check and still fail later.
  • HPOS, sync off. Orders only in the new tables. wp_posts holds nothing or stale leftovers, and old code returns empty.

The compatibility mode is the trap. It is a migration aid, not a permanent contract, and the day somebody turns it off to reclaim the write overhead is the day the plugin breaks - long after anyone connects the two events.

Proving which mode a store is in, before changing anything

Do not guess from symptoms. There are two reliable checks.

In the admin, WooCommerce > Settings > Advanced > Features shows the order data storage setting and whether compatibility mode is on. At runtime, WooCommerce will tell you directly:

function myplugin_hpos_active() {
	if ( ! class_exists( \Automattic\WooCommerce\Utilities\OrderUtil::class ) ) {
		return false; // WooCommerce older than 7.1 -- legacy storage, by definition.
	}

	return \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
}

add_action( 'admin_notices', function () {
	printf(
		'<div class="notice notice-info"><p>Order storage: %s</p></div>',
		myplugin_hpos_active() ? 'HPOS (custom order tables)' : 'legacy posts table'
	);
} );

The class_exists() guard is not defensive padding. OrderUtil arrived in WooCommerce 7.1, and a plugin that calls it unconditionally fatals on activation against an older install - turning a silent bug into a white screen, which is a different kind of bad day.

The portable replacement

The fix is to stop talking to tables and start talking to WooCommerce. The CRUD API has been the supported route since WooCommerce 3.0 and it works identically in both storage modes, which means you can deploy it before the store migrates and it keeps working after.

The same query, written portably:

$orders = wc_get_orders( array(
	'status'         => array( 'wc-completed' ),
	'limit'          => 50,
	'billing_email'  => '[email protected]',
	'orderby'        => 'date',
	'order'          => 'DESC',
	'type'           => 'shop_order',
) );

foreach ( $orders as $order ) {
	echo esc_html( sprintf(
		'%d %s %s',
		$order->get_id(),
		$order->get_billing_email(),
		$order->get_total()
	) );
}

Note limit, not posts_per_page, and that the default is 10 rather than "everything" - a detail that has produced more than one report of "the export only has ten lines". WC_Order_Query is the class behind wc_get_orders() and takes the same arguments if you prefer the object form:

$query = new WC_Order_Query( array(
	'limit'        => -1,
	'status'       => array( 'wc-processing', 'wc-on-hold' ),
	'date_created' => '>=' . strtotime( '-30 days' ),
	'return'       => 'ids',
) );

$order_ids = $query->get_orders();

foreach ( $order_ids as $order_id ) {
	$order = wc_get_order( $order_id );
	if ( ! $order instanceof WC_Order ) {
		continue;
	}
	// ...
}

The translation table is short and mechanical:

LegacyPortable
WP_Query / get_posts on shop_orderwc_get_orders() / WC_Order_Query
get_post( $order_id )wc_get_order( $order_id )
get_post_meta( $id, '_billing_email', true )$order->get_billing_email()
get_post_meta( $id, '_custom_key', true )$order->get_meta( '_custom_key' )
update_post_meta( $id, '_custom_key', $v )$order->update_meta_data(...) then $order->save()
$post->post_status$order->get_status() (no wc- prefix)

That last row catches people: get_status() returns completed, while the query argument wants wc-completed. Comparing the two directly gives you another silent false.

Custom meta, and the save() nobody remembers

Order meta still exists under HPOS; it just lives in its own table and is reached through the order object.

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

// Read
$ref = $order->get_meta( '_erp_reference', true );

// Write -- update_meta_data() only stages the change in memory.
$order->update_meta_data( '_erp_reference', 'ERP-' . $order->get_id() );
$order->add_order_note( 'ERP reference attached.' );
$order->save();

update_post_meta() wrote to the database immediately. update_meta_data() does not: it mutates the in-memory object, and save() is what persists it. Code mechanically converted from the old API without adding the save() appears to work in the same request - read it back and the value is there - and loses everything the moment the request ends. If you are changing several fields, batch them and call save() once; each call is a real write.

You can filter on custom meta through wc_get_orders():

$orders = wc_get_orders( array(
	'limit'      => -1,
	'status'     => 'wc-completed',
	'meta_query' => array(
		'relation' => 'AND',
		array(
			'key'     => '_erp_reference',
			'compare' => 'NOT EXISTS',
		),
		array(
			'key'     => '_delivery_window',
			'value'   => 'evening',
			'compare' => '=',
		),
	),
) );

Be aware of the limits. meta_query here is handled by WooCommerce's own query layer, not by WP_Query, and it is not the fully documented public surface that the named arguments are. It covers the common comparisons; it is not a promise that every exotic WP_Query meta clause translates. If a meta key is central to how you select orders, the durable answer is not a cleverer meta_query - it is a first-class column or a small table of your own, joined by order ID.

Declaring compatibility

Once the queries are genuinely fixed, tell WooCommerce so it stops listing your plugin as incompatible on the features screen:

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

This must run on before_woocommerce_init, and __FILE__ must be the main plugin file, so in practice the block goes in that file and nowhere else.

And now the honest part, because we have seen this done backwards more than once: declaring compatibility is a claim, not a migration. It changes one warning on one admin screen. It does not touch your queries. Adding it to an unfixed plugin does nothing except remove the notice that was accurately telling the store owner their orders were about to disappear from your reports. Fix the queries, verify, then declare.

Confirming the fix

The cheapest proof is a count comparison on a staging copy, run once in each storage mode:

/**
 * wp eval-file parity-check.php -- run on a staging copy, never production.
 */
$legacy = get_posts( array(
	'post_type'      => 'shop_order',
	'post_status'    => 'any',
	'posts_per_page' => -1,
	'fields'         => 'ids',
) );

$modern = wc_get_orders( array(
	'limit'  => -1,
	'status' => array_keys( wc_get_order_statuses() ),
	'return' => 'ids',
) );

printf(
	"legacy query: %d rows\nwc_get_orders: %d rows\nhpos enabled: %s\n",
	count( $legacy ),
	count( $modern ),
	myplugin_hpos_active() ? 'yes' : 'no'
);

On a legacy store the two counts should match. Flip the store to HPOS and re-run: the legacy count collapses, the wc_get_orders() count holds. That divergence is the bug, demonstrated on demand, and it is the thing to put in front of a client who is not sure the work is necessary. Every snippet here is syntax-checked, but written against the WooCommerce API rather than executed on a live store, so run the parity check on your own staging copy before you trust a migration.

When not to do this

If you own the store outright, you can keep it in legacy mode and nothing breaks tomorrow. That is a legitimate short-term call when the release calendar is full - but it is a deferral, not a fix. Legacy order storage is on a path to removal, compatibility mode costs you double writes in the meantime, and the work does not get smaller while you wait. Put a date on it.

The other case is a plugin that never reads orders at all. Plenty of plugins touch products, coupons or shipping and never go near shop_order. Those need the compatibility declaration so the store owner is not nagged, and nothing else. Read before you rewrite.

If a store you rely on has gone quiet in exactly this way, or you have an old plugin that needs bringing forward, that is the WordPress plugin work we do, and you can see how we work. Start a project with the plugin and the symptom and we reply within two business days.

We ship and run our own products. We build yours the same way.

Start a project

Join the article list

We are collecting addresses now and will email new articles once the newsletter starts. No spam, unsubscribe any time.

Hour Blocks from $180reply within two business days Start a project