The WooCommerce architecture
HPOS and Where Order Data Lives Now
A support ticket says the invoice is blank. The plugin that builds it reads order meta straight from the database, and on an HPOS store it finds nothing at all.
An invoice extension that has worked on the same store for four years starts printing
empty billing addresses. Nothing was deployed. The store owner enabled High-Performance
Order Storage, the plugin kept reading _billing_email from
wp_postmeta, and the value it used to find is no longer stored there.
The read does not error. It does not warn. get_post_meta() returns an
empty string, which is exactly what it returns when a key was never set, so the template
renders a blank line and support gets a ticket. That silence is the whole problem with
raw order access in 2026, and it is why this course starts with storage rather than with
hooks.
This course is written against WooCommerce 11.2 and WordPress 7.0, PHP 8.3 or newer. WooCommerce 11.2.1 is the current stable release at the time of writing. Version-specific statements below are scoped to 11.x; behaviour older than that is described as behaviour, not as a number.
Orders were posts, and the posts table was never built for them
Before High-Performance Order Storage, an order was a WordPress post. The row in
wp_posts carried the order's database id, its status, and its timestamps.
Everything else went into wp_postmeta as rows keyed by that post id: the
billing address, the line items, the payment method. It was a clever use of what
WordPress already had, and it held together for a decade of small stores.
What broke it was volume and shape. Orders are written far more often than posts, and
they are read in ways posts never are: filtered by status and date, summed for reports,
and joined against customer records. All of that ran through tables shared with every
page, every revision and every unrelated plugin's meta. A store running a busy checkout
adds rows to wp_posts and wp_postmeta at the same time it is
rendering pages, and the two workloads fight for the same indexes.
What HPOS changed, and what it left alone
HPOS is not an API change. WC_Order still has get_total()
and get_billing_email(), and wc_get_order() still returns the
same object it always did. What changed is where those values are written, which is why
the code that went around the API instead of through it is the code that broke.
Order data is now spread across four tables
On an HPOS store the order lives in dedicated tables. They are created with the
{$wpdb->prefix}wc_ prefix, so a default install has
wp_wc_orders and friends. Each table holds a different kind of fact, and
knowing which one is which tells you what a given failure means.
| Table | What it holds |
|---|---|
wc_orders |
The order record itself: status, type, currency, customer_id, billing_email, date_created_gmt, and the total and tax amounts. One row per order. |
wc_order_addresses |
Billing and shipping addresses, one row each, with an address_type column. Name, company, street lines, city, state, postcode, country, email and phone. |
wc_order_operational_data |
How the order came to exist and what has been done to it: created_via, woocommerce_version, cart_hash, order_key, date_paid_gmt, date_completed_gmt, and the shipping and discount totals. One row per order. |
wc_orders_meta |
Everything your extension adds with update_meta_data(), plus WooCommerce's own custom keys. Keyed by order_id, meta_key, meta_value. |
A refund is an order of type shop_order_refund whose
parent_order_id points at the order it belongs to, not a separate post. If
your code looks for refunds by querying for a post parent, it finds nothing on HPOS for
the same reason the invoice was blank.
A store can be on either storage, so ask rather than assume
HPOS is the default for new stores in 11.x, which is not the same as being universal. A store that predates the feature may still be running the posts-based data store, and a store can sit in a period where both are reachable. The support surface of an extension is the union of both, so the code has to ask.
WooCommerce exposes a helper for this. OrderUtil lives in the
Automattic\WooCommerce\Utilities namespace, and
custom_orders_table_usage_is_enabled() is the method that answers the
question. It reflects whether the store is actually using the custom tables, not whether
the feature could be turned on.
<?php
use Automattic\WooCommerce\Utilities\OrderUtil;
if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) {
// The order data is in the wc_orders tables.
} else {
// The order data is in wp_posts and wp_postmeta.
}
The class_exists() guard matters for one specific case: a site where the
orders code is loading while an older WooCommerce is still active. On the version this
course targets the class is always there, but an extension that ships to stores you do
not control cannot assume that.
Do not read the option yourself
The setting behind the helper is an option named
woocommerce_custom_orders_table_usage_is_enabled, and you will find it
quoted in older tutorials. Reading it directly skips the feature checks the helper
performs, and it keeps reporting yes for a store whose tables failed to
build. Ask the helper.
An extension declares compatibility before it is asked to
When WooCommerce switches a store to HPOS, plugins get a say. A plugin that declares itself incompatible stops the switch, which is the correct behaviour for a plugin whose order handling would corrupt data on the new storage. That is also the reason your extension should say something.
The declaration is made on the before_woocommerce_init action, with
FeaturesUtil::declare_compatibility() from the same namespace as
OrderUtil. Passing true for the last argument declares
compatibility; passing false declares incompatibility and will block the
switch on that store.
<?php
/**
* Plugin Name: Sifero Order Notes
* Requires Plugins: woocommerce
*/
use Automattic\WooCommerce\Utilities\FeaturesUtil;
add_action( 'before_woocommerce_init', function () {
if ( class_exists( FeaturesUtil::class ) ) {
FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
} );
Put this in the main plugin file, not in an include, and pass __FILE__
from that file. The declaration is recorded against the plugin path WooCommerce knows
about, and a declaration registered from a nested file is recorded against that file
instead, so the store's compatibility list names a plugin that does not exist.
Declaring compatibility is not a promise that the code is correct. It tells
WooCommerce the store may switch without asking you first. If the extension still reads
orders through get_post_meta(), the switch will succeed and the extension
will quietly stop working, which is the worst available outcome.
The postmeta read returns nothing rather than something stale
This is the part worth stopping on, because the intuition most developers have is
wrong. They expect that an order sitting on the new storage might have leftover
wp_postmeta rows from before the migration, so a raw read would return old
values. That is not what happens on a store that has been on HPOS.
On HPOS, a new order is written to the wc_orders tables. WooCommerce also
maintains a row in wp_posts for compatibility, but that row carries the
placeholder post type shop_order_placehold and holds none of the order's
values. The billing email is written to wc_orders.billing_email and to
wc_order_addresses.email. Nothing writes it to
wp_postmeta.
So the lookup key is present and the row it points to is present, and the meta row you
want was never created. get_post_meta( $order_id, '_billing_email', true )
finds no row, and its documented behaviour when a key is absent is to return an empty
string. You get empty, which is indistinguishable from a customer who left the field
blank, and there is no error to grep for.
<?php
$order = wc_get_order( 1042 ); // 1042 is an order id on this store.
// On an HPOS store this prints an empty string.
var_dump( get_post_meta( 1042, '_billing_email', true ) );
// This prints the address the customer actually typed.
var_dump( $order->get_billing_email() );
string(0) ""
string(27) "ana@example.com"
You can see the same thing from SQL. The orders are there, and the postmeta rows are not:
SELECT COUNT(*) AS orders,
COUNT(billing_email) AS with_email
FROM wp_wc_orders
WHERE type = 'shop_order' AND status != 'trash';
SELECT COUNT(*) AS postmeta_rows
FROM wp_postmeta
WHERE post_id IN ( SELECT id FROM wp_wc_orders )
AND meta_key IN ( '_billing_email', '_billing_first_name' );
orders with_email
40 40
postmeta_rows
0
Reading that result is the moment the lesson lands: the data is not stale, it is
absent. Nothing needs migrating back. The code needs to stop asking
wp_postmeta about orders and ask the order instead, and the next lesson is
about the API that does that.
On a store running WooCommerce 11.2, place three test orders through checkout,
then write a small PHP file that finishes with a die() so it does not
depend on theme output, and load it with WP-CLI:
wp eval-file diagnose-storage.php
Your script must answer these questions in its output, and it must not use
$wpdb directly for the first three:
- Is the store using custom order tables? Which call did you use?
- Pick one of your order ids and print its billing email through the order object.
- Print what
get_post_meta()returns for_billing_emailon that same id. - Print what
get_post( $order_id )->post_typeis, so you have seen where the order id points. - Count the orders on the store, through the CRUD layer rather than with a raw query, and say which call you used.
Then answer in one sentence each: which of the two email reads you would ship, and what a developer who saw the empty string would wrongly conclude.
Show the solution and the reasoning
The script below is the one I would write for this. Note that it reads the order id
from the argument list rather than hardcoding it, so you can rerun it against whichever
order the list call reports.
<?php
use Automattic\WooCommerce\Utilities\OrderUtil;
$order_id = 0;
foreach ( $args as $arg ) {
if ( is_numeric( $arg ) ) {
$order_id = (int) $arg;
break;
}
}
$hpos = class_exists( OrderUtil::class )
&& OrderUtil::custom_orders_table_usage_is_enabled();
printf( "custom order tables: %s\n", $hpos ? 'yes' : 'no' );
if ( ! $order_id ) {
$orders = wc_get_orders( array( 'limit' => 1, 'orderby' => 'date', 'order' => 'DESC' ) );
$order_id = $orders ? $orders[0]->get_id() : 0;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
die( "no order found\n" );
}
printf( "order %d billing email via CRUD: %s\n", $order_id, $order->get_billing_email() );
printf( "order %d billing email via postmeta: %s\n", $order_id, var_export( get_post_meta( $order_id, '_billing_email', true ), true ) );
$post = get_post( $order_id );
printf( "post type for that id: %s\n", $post ? $post->post_type : '(none)' );
On an HPOS store the output reads roughly like this:
custom order tables: yes
order 1042 billing email via CRUD: ana@example.com
order 1042 billing email via postmeta: ''
post type for that id: shop_order_placehold
Three decisions carry the lesson.
Compatibility check before the code path. Every branch that touches
order data hangs off $hpos, and the branch you cannot test is the one you
did not notice you wrote. The class_exists() guard is deliberate: it costs
nothing and it prevents a fatal error on a store running an older WooCommerce, which is
a real support scenario even though this course targets 11.2.
Two reads of the same value, on purpose. Printing both is the point of the exercise. You are building the habit of asking which storage a read touches before you trust its result, and the empty string on the fourth line is the entire failure mode of the old approach in a single value.
Reading it from the object, not from the table. It is tempting to
query wp_wc_orders directly, because the column list is right there and
the query is short. That works until the store runs the posts-based data store, or
until a version changes a column, or until an object cache is holding a different
copy. The reason WC_Order exists is that it knows which of those is
true. The SQL in the lesson above is a diagnostic, not a pattern to ship.
There is a defensible middle ground worth naming. An extension that runs a nightly report over tens of thousands of orders can legitimately query the tables directly for speed, because it is reading and not writing. If you do that, gate the query on the storage check, use the table name helpers WooCommerce provides rather than hardcoding the prefix, and never read one order that way in a request that will later write through the CRUD layer. Mixing the two means the object and the database disagree.
Progress
Mark this lesson done and it stays marked in this browser.