Understanding HPOS (High-Performance Order Storage)

In this tutorial I would like to guide you through the High-Performance Order Storage (HPOS) feature in WooCommerce – how to turn it on for your store, how it may affect it and how to update your plugins in order to make them work with the latest WooCommerce versions.

What is HPOS exactly?

HPOS (High-Performance Order Storage) — is a more performant way to store WooCommerce orders in the database. It is turned on by default for new stores since 8.2. Before the orders were just a custom post type shop_order and order data was stored in wp_posts and wp_postmeta tables but now we have a bunch of database tables specifically for WooCommerce orders.

Database structure

Right now I would like to guide you through the high-performance WooCommerce order tables. Also I am about to provide you an example – why are they so performant anyway?

There are actually three main tables.

First, wp_wc_orders – it stores all the main order information.

wp_wc_orders WooCommerce HPOS database table

Second, wp_wc_order_addresses – billing and shipping addresses are stored here.

wp_wc_order_addresses WooCommerce HPOS database table

Third, wp_wc_order_operational_data – it is the addition to the main order information table. These fields are related to the order internal state and kept in a separate table, because the changes to this table are expected to be more frequent.

wp_wc_order_operational_data WooCommerce HPOS database table

Fourth, wp_wc_orders_meta (wait, what? you said there are only three ones). This table is not exactly in use, but it is kind of similar to wp_postmeta and can be used by other plugins.

wp_wc_orders_meta WooCommerce HPOS database table

The way orders are presented in the database now is much more performant, for example let’s take a look at a simple SQL query where we’re trying to get orders by a billing country.

This is query we need to run for CPT-based order storage:

SELECT wp_posts.ID
FROM wp_posts
INNER JOIN wp_postmeta AS country ON wp_posts.ID = country.post_id AND meta_key = '_billing_country'
WHERE wp_posts.post_type = 'shop_order'
AND country.meta_value = 'DK'

And this is the same query for HPOS-based orders:

SELECT wp_wc_orders.id
FROM wp_wc_orders
INNER JOIN wp_wc_order_addresses AS address ON address.order_id = wp_wc_orders.id AND address.address_type = 'billing'
WHERE address.country = 'DK'

If you have 5000 orders or so in your database, the last query is going to be 4x times faster! In other words – the more orders you have in your store, the more performant HPOS is going to be for you. One of the reasons is – we don’t have to deal with bloated wp_posts and wp_postmeta (especially wp_postmeta) anymore.

How to turn HPOS on?

The long story short you can just go to WooCommmerce > Settings > Advanced > Features and switch to HPOS-based and back to CPT-based orders.

High performance order storage settings in WooCommerce

But there are some nuances to keep in mind:

  • HPOS is enabled for all new stores by default since WooCommerce 8.2.
  • You can not switch to HPOS if you have incompatible plugins.
  • You can not switch to HPOS or back if you have unsynced orders in your store (you can remove them by the way or just click the “Sync X pending orders” link below the setting).
  • There is also a “Compatibility mode” checkbox, basically what it does, it uses both high-performance order storage and CPT-based storage for your orders (it duplicates them, yes – not good for performance at all, but very good for compatibility).

Making your Plugins Compatible with HPOS

That actually an interesting topic to discuss. I am more than sure that a lot of folks out there are were using WP_Query to get WooCommerce orders or something like that. With HPOS it becomes almost impossible. But if from the very beginning you were reading my tutorials (this one for example) and always used CRUD methods to interact with WooCommerce orders, then probably you don’t even need to change anything in your code (I haven’t changed a line in my plugins – everything just works as before).

Anyway let’s just take a look at a couple of moments we have to keep in mind.

Example code changes

The key idea here is to stop using WordPress functions and start using WooCommerce CRUD-based functions, classes and their methods. Below are some of the examples.

Get order information:

// Instead of this:
// $post = get_post( $post_id ); // returns WP_Post object
// $order_status = $post->post_status;
// Use this:
$order = wc_get_order( $post_id ); // returns WC_Order object
$order_status = $order->get_status();

Get orders:

// Instead of this:
// $query = new WP_Query( array( 'post_type' => 'shop_order', 'posts_per_page' => 10 ) );
// Use this:
$query = new WC_Order_Query( array( 'limit' => 10 ) );
$orders = $query->get_orders();

// Instead of this:
// $orders = get_posts( array( 'post_type' => 'shop_order', 'posts_per_page' => 10 ) );
// Use this:
$orders = wc_get_orders( array( 'limit' => 10 ) );

Get and update order meta:

// Instead of this:
// $custom_meta = get_post_meta( $order_id, '_misha_key', true );
// $custom_meta = $custom_meta .= 'test'; // just change something
// update_post_meta( $order_id, '_misha_key', $custom_meta );
// Use this:
$order = wc_get_order( $order_id );
$custom_meta = $order->get_meta( '_misha_key', true );
$custom_meta = $custom_meta .= 'test'; // just change something
$order->update_meta_data( '_misha_key', $custom_meta );
$order->save();

Check post type:

// Instead of this:
// if( 'shop_order' === get_post_type( $post_id ) ) {
// Use this:
use Automattic\WooCommerce\Utilities\OrderUtil; // at the beginning of the file

if( 'shop_order' === OrderUtil::get_order_type( $post_id ) ) {

But since WooCommerce creates order placeholders with the matching IDs, you can also check it that way: 'shop_order_placehold' === get_post_type( $post_id ) with no need to use OrderUtil class.

Orders placeholders

An interesting backward compatibility moment to keep in mind – every HPOS-order will have its own “order placeholder” which is a post of a custom type shop_order_placehold created in wp_posts table. It contains the actual order ID but almost all the other columns are empty in this database row. Nothing gets written into wp_postmeta table as well.

example of a shop order placeholder in WooCommerce database

Check if HPOS is enabled

In the official documentation it is recommended to use the OrderUtil class in order to perform the check.

use Automattic\WooCommerce\Utilities\OrderUtil;

if( OrderUtil::custom_orders_table_usage_is_enabled() ) {
	// HPOS is enabled.
} else {
	// CPT-based orders are in use.
}

Declare compatibility in plugins

The cool news is that in case we can not add HPOS support for our custom plugin for one reason or another, we can let WooCommerce know about it.

Below is an example of a plugin incompatible with the high-performance order storage:

<?php
/*
 * Plugin name: HPOS compatibility check
 * Version: 1.0
 * Author: Misha Rudrastyh
 * Author URI: https://rudrastyh.com
 */
add_action( 'before_woocommerce_init', 'rudr_hpos_compatibility' );

function rudr_hpos_compatibility() {
	
	if( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 
			'custom_order_tables', 
			__FILE__, 
			false // true (compatible, default) or false (not compatible)
		);
	}
	
}

Currently there is nothing to stop you from activating a plugin like that on your HPOS-store, but if you already have a store with this plugin active you can not just switch to HPOS in settings.

WooCommerce High Performance Order Storage plugins compatibility check
You can’t switch to “High-performance order storage” while have an activated incompatible plugin.

Screen ID changes

Since orders aren’t a post type anymore (but they’re still using the same WP_List_Table class), we can not use the standard post type screen ID in our conditions. The new one is woocommerce_page_wc-orders. Ok, let me show you a couple of examples.

In the first example we’re just using admin_notices action hook to print something.

add_action( 'admin_notices', 'rudr_woo_notice_example' );
function rudr_woo_notice_example() {
	
	$screen = get_current_screen();
	// before HPOS we used this:
	// if( 'edit-shop_order' === $screen->id ) {
	if( 'woocommerce_page_wc-orders' === $screen->id ) {
		echo '<div class="notice notice-info"><p>Hey there</p></div>';
	}
	
}

Here we go:

admin notices in HPOS orders WooCommerce

In the second one we’re about to add a custom column to our orders table. Because guess what? Column hooks are using screen ID as a part of a hook name.

// previously we used manage_edit-{POST TYPE}_columns hook like this:
// add_filter( 'manage_edit-shop_order_columns', function( $columns ){
add_filter( 'manage_woocommerce_page_wc-orders_columns', function( $columns ) {
	
	$columns[ 'misha_column' ] = 'Info from Misha';
	return $columns;
	
} );

// previously we used:
// add_action( 'manage_posts_custom_column', function( $column_name, $order_id ){
add_action( 'manage_woocommerce_page_wc-orders_custom_column', function( $column, $order ){

	if( 'misha_column' === $column ) {
		echo 'hi there';
	}

}, 25, 2 );

Please notice, that in the second hook we not only have the hook name changed but we also have the whole WC_Order object available as a second parameter, so we can use $order->get_id() or whatever. Previously we had only $order_id available.

Oh yes, here is the column:

WooCommerce orders HPOS create a table columns

Another example – creating custom meta boxes for “Edit order” pages. We can not provide a shop_order value into the add_meta_box() function when creating a meta box as before. Instead we have to use wc_get_page_screen_id( 'shop-order' ) function. Example:

add_action( 'add_meta_boxes', function() {
	// before we used
	// add_meta_box( 'misha', 'Meta Box', 'misha_metabox', 'shop_order' );
	add_meta_box( 'misha', 'Meta Box', 'misha_metabox', wc_get_page_screen_id( 'shop-order' ) );
} );

function misha_metabox( $order ) { // WC_Order object is available here
	echo 'hey, this is an order with ID ' . $order->get_id();
}

Here we go:

WooCommerce orders custom meta box with HPOS support

It also possible to make your plugin work both ways with this condition:

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;

add_action( 'add_meta_boxes', function() {

	$screen = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
		? wc_get_page_screen_id( 'shop-order' )
		: 'shop_order';
	
	add_meta_box( 'misha', 'Hey', 'misha_metabox', $screen );
	
	...

And don’t forget to take into consideration that the main argument in the callback function is either an $object_id or $order depending on whether HPOS is active or not. So we also have to do this:

function misha_metabox( $order_or_post_id ) {
	
	$order = ( $order_or_post_id instanceof WP_Post ) 
		? wc_get_order( $order_or_post_id->ID ) 
		: $order_or_post_id;
	
	echo 'hey, this is an order with ID ' . $order->get_id();

Maybe I will add more examples later. For now – let’s continue the discussion in the comments.

Misha Rudrastyh

Misha Rudrastyh

Hey guys and welcome to my website. For more than 10 years I've been doing my best to share with you some superb WordPress guides and tips for free.

Need some developer help? Contact me

Follow me on X