Legacy WooCommerce code treats orders as WordPress posts. It queries them with WP_Query and post_type => 'shop_order', reads data with get_post_meta(), writes it with update_post_meta(), and sometimes goes straight to wp_posts and wp_postmeta with $wpdb. Under High-Performance Order Storage (HPOS), orders live in dedicated relational tables: {prefix}wc_orders, {prefix}wc_order_addresses, {prefix}wc_order_operational_data, and {prefix}wc_orders_meta. HPOS has been the default for new installs since WooCommerce 8.2. These dedicated database structures are commonly known as WooCommerce custom order tables, the original name for HPOS, and are designed to reduce the performance overhead associated with storing orders in the generic WordPress posts and postmeta tables.
While compatibility mode is on, WooCommerce keeps writing to both stores, and legacy code keeps working. This backward compatibility mechanism allows older WooCommerce extensions to continue accessing synchronized order data during migration, but it should not be treated as a permanent replacement for HPOS-compatible code. That hides the problem. Once HPOS is the authoritative store and sync is off, the post tables stop receiving updates, and legacy reads return nothing or stale data. No fatal error, notice, or log entry appears. A fraud check that looks up prior orders by meta finds none, a loyalty integration never sees new orders, and a custom checkout field saves to a table nothing reads. The failure is silent, which is why it needs to be found by audit rather than by error logs.
The EAV Bottleneck vs. HPOS
In the legacy model, an order is one row in wp_posts plus a stack of rows in wp_postmeta, one per attribute: totals, billing and shipping fields, payment method, and every custom field. Typical orders generate roughly 40 to 50 meta rows. That Entity-Attribute-Value layout has a cost at query time. meta_value is an unindexed LONGTEXT column, and filtering on two attributes means two self-joins against a table that can hold millions of rows. Sorting by order total means casting text to numbers. On a busy store, these queries are slow even on dedicated hardware.
HPOS improves database normalization by separating order information into dedicated relational tables, reducing dependence on the legacy Entity-Attribute-Value (EAV) storage model while preserving a separate metadata table for custom order attributes. Fields that every order has live in typed, indexed columns: status, total_amount, customer_id, date_created_gmt, billing email, and so on. wc_orders_meta holds only genuinely custom data, with indexes designed for lookups by order and key. WooCommerce's published benchmarks show roughly 5x faster order creation, about 1.5x faster checkout, and up to 40x faster filtering on indexed fields.
One detail matters for planning: those benchmarks were run with HPOS-to-posts sync disabled. Compatibility mode writes every change twice and schedules background synchronization batches through Action Scheduler, which gives back part of the gain. To get the full benefit, every integration touching orders has to work without the posts copy, so sync can be turned off. That is the actual migration task, and it starts with the audit:
grep -rnE "shop_order|get_post_meta|update_post_meta|wp_postmeta|wp_posts" \
wp-content/plugins/your-plugin wp-content/themes/your-theme --include=*.php
Every hit that operates on an order ID is a candidate for refactoring. Hits on product, page, or user data are not.
The Legacy Failure
This is a typical pre-HPOS pattern: a custom checkout field saved with the post meta API, then queried with WP_Query.
// Legacy: writes to wp_postmeta and reads from wp_posts.
add_action( 'woocommerce_checkout_update_order_meta', function ( $order_id ) {
if ( ! empty( $_POST['billing_delivery_zone'] ) ) {
update_post_meta(
$order_id,
'_billing_delivery_zone',
sanitize_text_field( wp_unslash( $_POST['billing_delivery_zone'] ) )
);
}
} );
function legacy_get_orders_by_zone( $zone ) {
$query = new WP_Query( array(
'post_type' => 'shop_order',
'post_status' => array( 'wc-processing', 'wc-on-hold' ),
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
array(
'key' => '_billing_delivery_zone',
'value' => $zone,
),
),
) );
return $query->posts;
}
function legacy_get_zone( $order_id ) {
return get_post_meta( $order_id, '_billing_delivery_zone', true );
}
With HPOS authoritative and sync off, each piece fails on its own:
update_post_meta()writes towp_postmeta, which HPOS does not read. The order object never sees the value.WP_Querysearcheswp_postsandwp_postmeta. Orders created after the switch aren't there, and older rows are stale, so the function returns an empty array.get_post_meta()on an order ID returns nothing for orders that never reached the posts tables.posts_per_page => -1makes the query unbounded, which on legacy storage was already a performance problem.
The hook woocommerce_checkout_update_order_meta still fires, so nothing looks broken in a quick test. Only the write target is wrong.
The HPOS-Compliant Abstraction Layer
The fix is to stop addressing storage and talk to the order object. The WooCommerce core CRUD API (Create, Read, Update, Delete) provides a storage-independent interface through functions such as wc_get_order(), wc_get_orders(), and the WC_Order class, allowing developers to interact with order data without querying database tables directly. WooCommerce's CRUD layer resolves to whichever data store is active. First, plugins must declare compatibility, or WooCommerce flags them as incompatible in the Features settings screen:
use Automattic\WooCommerce\Utilities\FeaturesUtil;
add_action( 'before_woocommerce_init', function () {
if ( class_exists( FeaturesUtil::class ) ) {
// __FILE__ must be the main plugin file.
FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
} );
Write the checkout field using the order object that WooCommerce is about to save:
add_action( 'woocommerce_checkout_create_order', function ( WC_Order $order, array $data ) {
// WooCommerce has already verified the checkout nonce at this point.
if ( ! empty( $_POST['billing_delivery_zone'] ) ) {
$order->update_meta_data(
'_billing_delivery_zone',
sanitize_text_field( wp_unslash( $_POST['billing_delivery_zone'] ) )
);
}
}, 10, 2 );
Do not call $order->save() inside this hook. woocommerce_checkout_create_order fires before WooCommerce saves the order, so your meta is persisted in the same operation, with no extra write.
Query orders with wc_get_orders() instead of WP_Query:
function mcf_get_orders_by_zone( string $zone, int $limit = 50 ): array {
return wc_get_orders( array(
'status' => array( 'processing', 'on-hold' ),
'limit' => $limit,
'orderby' => 'date',
'order' => 'DESC',
'return' => 'objects',
'meta_query' => array(
array(
'key' => '_billing_delivery_zone',
'value' => $zone,
),
),
) );
}
When the value you are filtering on is a core field, use the native arguments (customer_id, billing_email, date_created, status) before reaching for meta_query. They map to indexed columns in wc_orders and the address tables, while meta queries search wc_orders_meta.
Read and update data outside the checkout hook through the object, and call save() yourself:
function mcf_flag_outer_zone_order( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return;
}
if ( 'zone_c' === $order->get_meta( '_billing_delivery_zone', true ) ) {
$order->update_meta_data( '_zone_review_status', 'flagged' );
$order->save();
}
}
update_meta_data() only changes the object in memory and marks the value as dirty. save() hands the object to the active data store, which writes only the changed properties: the relevant columns in wc_orders and its sibling tables, plus the dirty rows in wc_orders_meta, using the table's indexes. If compatibility mode is on, the same call also syncs to the posts tables, with no change to your code. When only meta changed, $order->save_meta_data() persists just the meta and skips the order-level save hooks.
Several other legacy calls need replacing during the same pass:
get_post_type( $order_id )becomesOrderUtil::get_order_type( $order_id ).$post->post_dateand similar properties become$order->get_date_created()and the other getters.- Admin screen checks against the
shop_orderpost type becomewc_get_page_screen_id( 'shop-order' ). - Branching on storage type, where truly unavoidable, uses
OrderUtil::custom_orders_table_usage_is_enabled(). - Raw
$wpdbqueries againstwp_postsorwp_postmetafor orders move towc_get_orders().
To verify the refactor, disable compatibility mode on a staging copy and run the full checkout path. Check that the custom field appears on the order, that your query returns the new orders, and that nothing depends on the posts tables. The failures described above only show up when sync is off.
Related Architecture Guides: If your store also uses the React-based Gutenberg Block Checkout alongside HPOS, classic $_POST meta hooks will not fire. Read our companion guides on persisting custom field data in WooCommerce Block Checkouts via woocommerce_store_api_register_update_callback and extending Checkout Blocks with woocommerce_store_api_register_endpoint_data schemas. To further reduce database lock contention during HPOS order creation, combine custom order tables with isolated Redis session locking.
Conclusion
Routing every order interaction through WC_Order and wc_get_orders() decouples your code from storage. WooCommerce's data store layer decides where the data lives, so the same calls work on legacy posts storage, on HPOS, and on whatever replaces them. Code written against the CRUD layer needed no changes when HPOS arrived, while code written against wp_postmeta broke quietly. Raw SQL against wc_orders would repeat the mistake, binding you to a schema that is an implementation detail. Declare compatibility, refactor to the object API, and turn off synchronization after successful testing. To evaluate performance improvements, compare Time to First Byte (TTFB), checkout response times, and database query execution times before and after the HPOS migration, particularly for WooCommerce AJAX requests such as ?wc-ajax=update_order_review and ?wc-ajax=checkout.

0 Comments