Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 01 / 06 30 MIN

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.

TableWhat 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.

Lab 25 minutes

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_email on that same id.
  • Print what get_post( $order_id )->post_type is, 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.