Skip to content
System nominalUTC --:--:--Articles 041

What Actually Changes When WooCommerce Orders Leave Postmeta

On a store that has moved to HPOS, get_post_meta returns nothing and a direct wp_postmeta query returns stale rows. Here is what to replace both with.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Topic
WOO
Section
WooCommerce & Storefront
Published
31 AUG 2026
Reading time
8 MIN
Words
1946W
Technical cover graphic for What Actually Changes When WooCommerce Orders Leave Postmeta
Generated schematic — WOO series, REV 2.6.

A plugin update ships. Staging looked fine. Then a customer writes in, a support agent opens the order to check something, and the panel your plugin adds is empty. No fatal error, no notice in the log, just a blank field where a tracking number or a gift message should be. The agent sends it to a developer, the developer loads the same order on staging, and the field is there.

The difference between the two environments is not the plugin. It is that the production store completed its migration to WooCommerce High-Performance Order Storage and staging did not. The plugin reads order metadata through get_post_meta(), and on an HPOS store there is no longer a postmeta row to read. On WooCommerce 8.x a well-behaved plugin has a compatibility declaration, a CRUD path for every read and write, and no raw wp_postmeta queries left, and getting to that state means understanding which storage backend is answering.

Postmeta stops being the order store, and stops being authoritative

In the classic schema, an order was a post of type shop_order. The order's own fields sat in wp_posts and everything else, billing email included, sat in wp_postmeta as key and value pairs. That is why get_post_meta( $order_id, '_billing_email', true ) was correct for a decade.

Once custom order tables are the authoritative store, that call has three possible outcomes and only one of them is what you want. On a store that never migrated, it returns the value. On a store that migrated with sync mode still on, it usually returns the value because both stores are being written. On a store that completed the migration and turned sync off, it returns an empty string.

That middle case is the reason this migration hurts. Sync mode made the broken code look correct for months, so nothing was fixed while the safety net was up. The break arrived later, when the net came down, and by then the code that caused it had been forgotten.

Four tables replace one posts row and its meta

The HPOS schema splits what used to be a single post plus an unbounded meta table into four tables, each with a narrower job. You do not need to query them by hand, but knowing which one holds what makes the failures easier to reason about.

TableHoldsClassic equivalent
wc_orders Identity, status, type, customer ID, currency, totals, the parent order for refunds. Columns on wp_posts plus some meta.
wc_order_addresses Billing and shipping address blocks, one row per address type. _billing_* and _shipping_* meta keys.
wc_order_operational_data Dates and flags the storefront reads in bulk: created, paid, completed, prices include tax. Underscore-prefixed meta such as _date_paid.
wc_orders_meta Everything else, including your plugin's own keys. wp_postmeta for the order post.

The practical consequence is in the last row. Your key did not change. The table it lives in did, and nothing on the read path warns you when you look in the wrong one.

This is also why the migration produces a class of bug that is worse than an error. A query such as SELECT meta_value FROM wp_postmeta WHERE post_id = 4127 AND meta_key = '_wpsifero_tracking_id' does not throw. On a migrated store with sync off, the migrated rows may still be sitting there from before the switch, so the query returns the value the order had at migration time. Your code happily displays a tracking number that is three months old, and the customer is told to expect a parcel that arrived in June.

The CRUD layer is the only read path that survives the switch

WooCommerce solved both problems with the same answer. wc_get_order() returns an order object, and that object knows which backend is active. Every read and write goes through the object, and the object routes it. Your plugin stops caring where the row lives.

// Before: correct only while orders are posts.
$email  = get_post_meta( $order_id, '_billing_email', true );
$note   = get_post_meta( $order_id, '_wpsifero_delivery_note', true );
$status = get_post_status( $order_id ); // 'wc-processing', on a good day.
// After: correct on either backend.
$order = wc_get_order( $order_id );

if ( ! $order ) {
    return;
}

$email  = $order->get_billing_email();
$note   = $order->get_meta( '_wpsifero_delivery_note' );
$status = $order->get_status();

Two habits come with the rewrite. First, check that wc_get_order() returned an object. It returns false for an order that does not exist or has been trashed, and a chained call on false is a fatal error on PHP 8. Second, use the object's own getters for core fields. get_billing_email() and get_status() exist, they pass through the same storage abstraction, and they return the value in the shape you expect. Reading a core field through get_meta() works, but it bypasses the typed accessor and the shape difference will find you eventually.

Writes follow the same rule and have one more requirement.

$order = wc_get_order( $order_id );

$order->update_meta_data( '_wpsifero_delivery_note', 'Leave with the concierge' );
$order->save(); // Without this, nothing is persisted.

update_meta_data() stages the change on the in-memory object. save() writes it. A hook that stages and returns will pass every code review and change nothing in the database, which usually shows up as a plugin that works when the order is edited from the admin screen and does nothing when the change comes from a webhook.

Declare compatibility, or WooCommerce treats your plugin as the blocker

WooCommerce will not move an order store to the new tables while a plugin might still depend on postmeta. It builds a list of active plugins that have not declared compatibility, surfaces it in the settings screen, and leaves the decision with the site owner, who is now looking at your plugin's name and wondering whether they can proceed.

I have watched this play out badly. A team spent a week auditing someone else's plugin for postmeta reads, rebuilt a custom order report around the CRUD layer, and then discovered the actual blocker was their own twenty-line internal helper that had been calling get_post_meta() since 2019. The compatibility checker names every plugin but it cannot tell you which line inside it is the problem.

The declaration is small and it belongs on the before_woocommerce_init hook, which fires before WooCommerce itself finishes loading. On WooCommerce 8.x, the feature ID you declare is custom_order_tables.

use Automattic\WooCommerce\Utilities\FeaturesUtil;

add_action( 'before_woocommerce_init', function () {
    if ( class_exists( FeaturesUtil::class ) ) {
        FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
    }
} );

The class_exists() guard is there because a store on an older WooCommerce will not have the class, and an unguarded reference to it is a fatal error at exactly the moment you can least afford one. The third argument is what you are asserting: that your plugin is compatible. If your plugin still queries wp_postmeta or wp_posts for order data, declare it as false and fix the code. Declaring compatibility you do not have buys you a migration that breaks in production instead of a migration that waits.

Sync mode is a bridge, and it charges rent

Sync mode keeps both stores updated. When the classic tables are still authoritative, an order saved through the CRUD layer is written to postmeta and mirrored into the HPOS tables. When the HPOS tables are authoritative, the direction reverses and the order is also written back to postmeta. Either way, the migration can be paused and rolled back at any point, which is what makes it safe to attempt on a store with a real order history.

What it is not is a destination. Every order save becomes two writes in two tables, plus the meta rows that go with them, so a store that writes to orders often pays roughly double on the write path for as long as sync stays on. On a test store running WooCommerce 9.x on PHP 8.3 with about 40,000 orders, a script that updated one meta field on 200 orders issued twice the write queries with sync on as it did with sync off. Wall-clock difference was small at that volume, which is worth knowing before you blame sync for a slow admin screen.

The divergence risk is the part that bites. In sync mode your database holds two copies of the same data, and nothing in WooCommerce guarantees they agree. If a write lands in one store and the request dies before the other, you have two answers to the same question, and you will find it by comparing the two tables rather than by reading a log.

Migration sequencing

On WooCommerce 8.x the safe order is: enable sync while the classic tables are still authoritative, let it run across your real traffic, verify a sample of orders in both stores, then switch the authoritative store, then turn sync off once no active plugin is in the incompatibility list. Leaving sync on after the switch keeps the write cost without the rollback benefit you were paying for.

Reading through CRUD and querying wp_postmeta are different operations

These two approaches look like the same read and are not. The distinction is worth stating plainly, because it is where the stale-data bugs come from.

A call to $order->get_meta( '_key' ) asks the order object a question. The object was loaded from whichever store is authoritative, and it resolves the metadata against the meta table that belongs to that store. If HPOS is authoritative, the answer comes from wc_orders_meta. If the classic tables are authoritative, it comes from wp_postmeta. You get a live value from the live store in both cases.

A query against wp_postmeta asks a specific table a question, and that table knows nothing about which store is authoritative. On a migrated store with sync off, migrated rows are often left in place as a record of what the order looked like before the switch. The query returns those rows. It returns them without an error, without a warning, and with a plausible value, which is precisely why this failure survives code review and staging.

You can see the split for yourself on a migrated store. Ask the order object for the note and you get today's value. Run SELECT meta_value FROM wp_postmeta WHERE post_id = 4127 AND meta_key = '_wpsifero_delivery_note' and you get whatever the row held the last time anything wrote to it. Same database, same key, same order. The two answers disagree, and only one of them is current.

That query is a diagnostic, not a read path. Reach for it when you suspect a plugin is still writing to postmeta and want to confirm the two stores have drifted apart.

If you genuinely need to query order metadata in bulk, say to find every order with a particular delivery note, the supported route is wc_get_orders() with a meta_query argument. It translates into the right table for the active store. A hand-written join against wp_postmeta will keep returning results for as long as the old rows exist, and then one maintenance window later it will return nothing at all.

What to do on Monday

  1. Search the codebase for get_post_meta, update_post_meta, delete_post_meta and any SQL that names wp_postmeta or wp_posts while holding an order ID. Each hit is a bug with a delayed fuse.
  2. Replace reads with the order object: wc_get_order(), then $order->get_meta() for your keys and the typed getters for core fields. Keep the false check.
  3. Replace writes with update_meta_data() followed by save(), and test each one from a path that does not go through the admin order editor.
  4. Add the FeaturesUtil::declare_compatibility() call behind a class_exists() guard, and declare false until the first three steps are actually done.
  5. Test the whole thing twice: once with sync mode on, once with it off. The second run is the one that matters, and it is the one most teams skip.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Covers the WooCommerce order lifecycle: checkout, HPOS, session handling, transactional email and the post-purchase layer where most storefront complaints actually originate.

Continue

Related in WooCommerce & Storefront