WooCommerce HPOS: Making Your Code Compatible

WooCommerce HPOS: Making Your Code Compatible


For most of its history, WooCommerce stored orders as a kind of WordPress post. Every order lived in the same wp_posts table as your pages and products, and every order detail (billing email, totals, payment method) was a separate row in wp_postmeta. It worked, but on busy stores those tables grew enormous and order queries got slow.

High-Performance Order Storage (HPOS) fixes that by moving orders into dedicated tables designed for them. It has been the default for new WooCommerce installations since version 8.2, and existing stores can switch over once their code and extensions are compatible. If you maintain a WooCommerce store, a custom plugin or a theme with order-related code, this guide explains what changed, what breaks, and exactly how to update your code. I’ll show the old and new patterns side by side.

What HPOS changes in the database

With HPOS enabled, orders no longer live in the posts tables. WooCommerce uses four tables of its own: one row per order in wc_orders, addresses in wc_order_addresses, internal flags in wc_order_operational_data, and any extra (extension) data in wc_orders_meta.

Legacy order storage keeps orders in wp_posts alongside pages, posts and products, with every order field as a key-value row in wp_postmeta. HPOS uses four dedicated tables: wc_orders, wc_order_addresses, wc_order_operational_data and wc_orders_meta.Legacy: the posts tablesOrders share tables with all your contentwp_postspagepostproductshop_orderOrders mixed in with everything elsewp_postmeta_billing_email a@example.com_order_total 49.00_payment_method stripe …HPOS: dedicated order tablesDesigned and indexed for orderswc_ordersOne row per order: status, totals, customer, dateswc_order_addressesBilling and shipping addresseswc_order_operational_dataInternal flags, cart hash, version infowc_orders_metaYour custom and extension order data
Figure 1: Legacy storage versus HPOS. Core order fields become real columns; only custom data stays as key/value meta. (Table names carry your site’s prefix, e.g. wp_wc_orders.)

The benefits are practical: fewer, smaller and better-indexed tables for order queries, a lighter wp_posts table for everything else, and an orders screen that stays responsive as the store grows. If your store is slow in general, HPOS is one of the structural fixes I describe in speeding up a slow WooCommerce store.

Why some code breaks

WooCommerce has had a proper CRUD API (objects like WC_Order, functions like wc_get_order()) for years. Code that uses it doesn’t care where orders are stored: WooCommerce’s data store reads from whichever tables are active. The code that breaks is code that went around the API and talked to the posts tables directly.

Code that uses the WooCommerce CRUD API (wc_get_order, get_meta, update_meta_data, save, wc_get_orders) works with both HPOS and legacy tables. Code that bypasses it with get_post_meta, WP_Query on shop_order or raw SQL only reaches the legacy posts tables and breaks under HPOS.Your plugin or theme codeWooCommerce CRUD APIwc_get_order()get_meta()update_meta_data()save()wc_get_orders()Works on both storage typesHPOS order tablesdefault for new storesPosts tables (legacy)older stores, or sync copyBypassing the APIget_post_meta()WP_Query shop_orderraw SQL on postmetaBreaks under HPOS
Figure 2: Code that goes through the CRUD API works on both storage types. Code that reaches into the posts tables directly only sees legacy data.

Under HPOS, those shortcuts either read nothing (the order isn’t in wp_posts any more), read stale data (if compatibility sync is on, the post copy may lag), or write data that WooCommerce never sees. The usual culprits are:

  • get_post_meta(), update_post_meta() and add_post_meta() with an order ID.
  • get_post( $order_id ) or wp_update_post() on orders.
  • WP_Query or get_posts() with 'post_type' => 'shop_order'.
  • Direct SQL against wp_posts or wp_postmeta for order data.
  • Post-specific hooks such as save_post_shop_order, and admin hooks tied to the old edit.php?post_type=shop_order screen.

Declaring compatibility

WooCommerce checks every active plugin before letting a store switch to HPOS. A plugin that doesn’t say anything is treated as incompatible and can block the switch. Once your code is updated, declare compatibility in the main plugin file:

<?php
/**
 * Plugin Name: Acme Order Tools
 */

add_action(
	'before_woocommerce_init',
	function () {
		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
				'custom_order_tables', // The HPOS feature key.
				__FILE__,              // Must be the main plugin file.
				true
			);
		}
	}
);

Two details catch people out: the call must run on before_woocommerce_init (later is too late), and __FILE__ must point to the main plugin file. If you put the declaration in an included file, pass the main file’s path instead. Only declare compatibility after you’ve actually updated and tested the code; the declaration is a promise, not a fix.

Reading and writing order data

This is the change you’ll make most often. Load the order object, use its getters and meta methods, and call save() when you’ve finished writing:

<?php
// Before: only works with legacy (posts) storage.
$tracking = get_post_meta( $order_id, '_acme_tracking_number', true );
update_post_meta( $order_id, '_acme_tracking_number', $number );
$email = get_post_meta( $order_id, '_billing_email', true );

// After: works with HPOS and legacy storage.
$order = wc_get_order( $order_id );
if ( $order ) {
	$tracking = $order->get_meta( '_acme_tracking_number' );
	$email    = $order->get_billing_email(); // Core fields have real getters.

	$order->update_meta_data( '_acme_tracking_number', $number );
	$order->save(); // Nothing is written until save().
}

Notice the difference between core fields and custom data. Billing email, totals, status and dates are real columns under HPOS, so use the matching getter (get_billing_email(), get_total(), get_status()) rather than reading their old meta keys. Your own custom data uses get_meta(), update_meta_data() and delete_meta_data().

The same applies to hooks. Instead of save_post_shop_order, use WooCommerce’s own order events, which fire regardless of storage:

<?php
// Runs when an order is created, whichever storage is active.
add_action(
	'woocommerce_new_order',
	function ( $order_id, $order ) {
		// $order is a WC_Order object.
	},
	10,
	2
);

// React to status changes rather than to generic "post saved" events.
add_action(
	'woocommerce_order_status_completed',
	function ( $order_id ) {
		$order = wc_get_order( $order_id );
		// ... send to fulfilment, sync to an ERP, etc.
	}
);

Querying orders

Replace WP_Query and get_posts() for orders with wc_get_orders(). It accepts similar arguments, including a meta_query for your custom fields, and returns order objects:

<?php
// Before: WP_Query on the shop_order post type (legacy only).
$query = new WP_Query(
	array(
		'post_type'   => 'shop_order',
		'post_status' => 'wc-processing',
		'meta_key'    => '_acme_synced',
		'meta_value'  => 'no',
	)
);

// After: wc_get_orders() works with both storage types.
$orders = wc_get_orders(
	array(
		'status'     => array( 'wc-processing' ),
		'limit'      => 50,
		'orderby'    => 'date',
		'order'      => 'ASC',
		'meta_query' => array(
			array(
				'key'   => '_acme_synced',
				'value' => 'no',
			),
		),
	)
);

foreach ( $orders as $order ) {
	// Each item is a WC_Order.
}

If you genuinely need raw SQL for a report, check which storage is active and query the right tables. WooCommerce provides a helper for that:

<?php
use Automattic\WooCommerce\Utilities\OrderUtil;

if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
	// Query {$wpdb->prefix}wc_orders and wc_orders_meta.
} else {
	// Legacy: query wp_posts / wp_postmeta.
}

Admin screens, columns and meta boxes

Under HPOS, orders are managed on a different admin screen (admin.php?page=wc-orders instead of edit.php?post_type=shop_order). Custom columns and meta boxes need to target it. The cleanest approach is to register for both, so your code works on any store:

<?php
// Add an "Acme sync" column to the orders list (HPOS and legacy screens).
function acme_add_order_column( $columns ) {
	$columns['acme_sync'] = __( 'Acme sync', 'acme' );
	return $columns;
}
add_filter( 'manage_woocommerce_page_wc-orders_columns', 'acme_add_order_column' ); // HPOS.
add_filter( 'manage_edit-shop_order_columns', 'acme_add_order_column' );            // Legacy.

// HPOS passes the order object; the legacy screen passes the post ID.
add_action(
	'manage_woocommerce_page_wc-orders_custom_column',
	function ( $column, $order ) {
		if ( 'acme_sync' === $column ) {
			echo esc_html( $order->get_meta( '_acme_synced' ) ?: '—' );
		}
	},
	10,
	2
);
add_action(
	'manage_shop_order_posts_custom_column',
	function ( $column, $post_id ) {
		if ( 'acme_sync' === $column ) {
			$order = wc_get_order( $post_id );
			echo esc_html( $order ? ( $order->get_meta( '_acme_synced' ) ?: '—' ) : '—' );
		}
	},
	10,
	2
);

// Meta box on the order edit screen, whichever screen is active.
add_action(
	'add_meta_boxes',
	function () {
		add_meta_box(
			'acme-sync',
			__( 'Acme sync', 'acme' ),
			'acme_render_sync_box', // Receives a WP_Post or a WC_Order.
			wc_get_page_screen_id( 'shop-order' ),
			'side'
		);
	}
);

Inside the meta box callback, accept either type: $order = $object instanceof WP_Post ? wc_get_order( $object->ID ) : $object;. For links to an order, use $order->get_edit_order_url() instead of building a post.php URL by hand.

Legacy patternHPOS-compatible replacement
get_post_meta( $id, '_key', true )wc_get_order( $id )->get_meta( '_key' )
update_post_meta()$order->update_meta_data() + $order->save()
get_post_meta( $id, '_billing_email', true )$order->get_billing_email()
WP_Query / get_posts() on shop_orderwc_get_orders()
save_post_shop_orderwoocommerce_new_order, woocommerce_update_order, status hooks
manage_edit-shop_order_columnsAlso manage_woocommerce_page_wc-orders_columns
add_meta_box( …, 'shop_order' )wc_get_page_screen_id( 'shop-order' )
admin_url( 'post.php?post=' . $id … )$order->get_edit_order_url()
A quick reference for updating order code.

Searching your codebase for shop_order, get_post_meta, update_post_meta, wp_postmeta and save_post finds most of what needs attention. My guide to WordPress hooks is useful background if you’re replacing post hooks with WooCommerce ones.

Switching a live store safely

For an existing store, the switch doesn’t have to be a leap. WooCommerce’s compatibility mode keeps both storage systems in sync, so you can move one step at a time and step back if something misbehaves.

Four stages of switching to HPOS: legacy only; legacy authoritative with sync to HPOS tables; HPOS authoritative with sync back to posts tables; and HPOS only with sync turned off. Switching back is easy while sync is on.1Legacy onlyPosts tables arethe source of truth.Update and test code.2Turn on syncOrders are copiedinto HPOS tables.Wait until fully synced.3HPOS + syncHPOS is the source oftruth; posts still copied.Run real orders, watch.4HPOS onlySync off; legacy orderdata can be cleaned up.Full performance gain.Sync on: switching back is quick and safeBack needs a re-syncStarting point
Figure 3: Moving an existing store to HPOS in stages. Compatibility mode (sync) makes each step reversible.
  1. Update and test the code on staging. Check WooCommerce → Settings → Advanced → Features: it lists any active plugins that haven’t declared compatibility.
  2. Enable compatibility mode on the live store and let WooCommerce copy existing orders into the HPOS tables in the background.
  3. Switch the source of truth to HPOS while keeping sync on. Place test orders, check emails, refunds, reports and every integration.
  4. Turn sync off once you’re confident, which gives you the full performance benefit.

On large stores, WP-CLI is more reliable than the browser for the heavy lifting. WooCommerce provides commands under wp wc hpos:

# Current HPOS and compatibility-mode state, plus pending counts
wp wc hpos status

# How many orders still need syncing?
wp wc hpos count_unmigrated

# Sync in batches (default batch size is 500)
wp wc hpos sync --batch-size=500

# Check both data stores agree; inspect a single order in detail
wp wc hpos verify_data
wp wc hpos diff 1234

As always with store changes, take a full database backup first, and pick a quiet time for the switch. If you’re moving the store to new hosting at the same time, do one change at a time; my WordPress migration guide covers the hosting side.

Key takeaways

  • HPOS moves orders from the posts tables into dedicated, indexed order tables.
  • Code that uses the CRUD API keeps working; code that touches posts tables directly breaks.
  • Use wc_get_order(), getters, get_meta()/update_meta_data() + save(), and wc_get_orders().
  • Support both order screens for columns and meta boxes, then declare compatibility.
  • Switch live stores in stages with compatibility mode, so every step is reversible.

Have a custom plugin or theme that’s blocking the switch to HPOS? I can audit the order-related code, update it to the CRUD API and help you migrate the store safely. Get in touch with a list of the plugins that WooCommerce flags.

Code examples in this post are free to use under the MIT licence. Article text © 2026 Farooq Bin Munir.