WooCommerce HPOS Explained – 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.
Is it really so fast?
The short answer is – yes!
| Task | Performance improvement |
|---|---|
| Order creation | 5x |
| Checkout | 1.5x |
| Order queries and search | up to 40x |
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.

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

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.

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.

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 the 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?
Long story short, you can just go to WooCommerce > Settings > Advanced > Features and switch to HPOS-based and back to CPT-based orders.

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 is 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 Order Sync and Multisite Order Sync 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 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.

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 WooCommerce HPOS 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.

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:

In the second one, we’re about to add a custom column to our orders table. Because guess what? Column hooks use 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:

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:

It is 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
Hey guys and welcome to my website. For more than 15 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
If you have time please share some internal woocommerce tables relation how they store and process data. It will be better for us before you give an intro relationship of core wordpress tables.
Hi Misha,
Thank you for this explanation. It helps me a lot to make my store ready for HPOS.
I updated most of my snips by following your examples.
I am dealing with some custom fields on the Woocommerce subscription admin screen. And try to understand how HPOS Sync is syncing custom post metadata from the old system to the new system.
I look forward to seeing more HPOS implementation, especially for custom posts like Woocommerce Subscription, Woocommerce membership, etc.
Once again, thank you for your help in providing such excellent, easy-to-understand examples.
Hi Mehmet,
Thank you! I will do my best to post more examples like this 🙏
Thank you very much for this very detailed and very useful post. I have a question regarding custom columns and integration into a plugin.
If a person has activated HPOS on their store and the plugin used has not modified manage_edit-shop_order_columns and manage_posts_custom_column, the column, according to my tests, is no longer displayed. And the opposite too. According to the documentation, one can use
use Automattic\WooCommerce\Utilities\OrderUtil;thenif (OrderUtil::custom_orders_table_usage_is_enabled()). So I declareduse Automattic\WooCommerce\Utilities\OrderUtil;then my class and in this my method but I get a fatal error: Fatal error: Uncaught Error: Class “Automattic\WooCommerce\Utilities\OrderUtil” not found in …An idea ?
Thanks
Hey Jonathan,
Please check this guide.
Was looking for this. Many thanks!
Hi. I wanna know if it is possible to display some specific orders on the basis of some conditions, like displaying the orders with the status on-hold only etc (on the order page).
Very useful, you’re awesome! The ‘woocommerce_page_wc-orders’ screen id was what I needed for my metabox to appear again (an array with that and ‘shop_order’ will cover both cases i’m sure)
Hey Misha, how can we add a filter on the order page? You added a column, so I need to add a filter as well on the orders page. Could you please let me know, which hook we can use for HPOS?
Hey, yes, absolutely, here is how.
Thanks for this detailed explanation, what about programmatic order creation, ive read your post on that, but what happens with HPOS?
The method I described works great with HPOS
Hi! Thank you for the interesting post. Is there a way to clean up the database from the doubled data stored during “Compatibility mode”? ie: to keep only one copy (the one using high-performance order storage)? thank you