wc_get_orders instead of WP_Query - how to write HPOS-compatible order code (and rewrite old snippets)
Somewhere in the child theme's functions.php sits a snippet from 2019. It pulls orders with get_posts( [ 'post_type' => 'shop_order' ] ), reads the invoice number with get_post_meta() and emails a report to accounting. It worked for six years. With HPOS on, it returns an empty array - and in compatibility mode it still returns something, only out of date. Nobody sees an error, because there is no error.
This post continues the one on what HPOS is and whether to enable it. That one covered "what it is and whether to switch", this one covers "how to write code that survives the switch". I am not repeating the description of the four tables, that is what the other post is for. Every snippet below I verified on WooCommerce 11.0.1 (20 September 2026).
This is not a full reference for wc_get_orders() parameters. That lives in the WooCommerce documentation. What follows is about which patterns break, what replaces them, and where the traps are that the docs mention in a single warning box.

Short answer (for the impatient)
wc_get_orders() and WC_Order_Query are the only supported way of fetching orders in WooCommerce. They behave identically on HPOS and on the legacy posts storage, because WooCommerce picks the tables, not your code. WP_Query, get_posts(), get_post_meta() and direct $wpdb queries against wp_posts or wp_postmeta read empty or stale data once HPOS is on. Order metadata is read with $order->get_meta() and written with $order->update_meta_data() followed by $order->save().
Four replacements that cover most cases:
| The old way | HPOS-compatible |
|---|---|
get_post( $order_id ) |
wc_get_order( $order_id ) |
get_post_meta( $id, '_key', true ) |
$order->get_meta( '_key' ) |
update_post_meta( $id, '_key', $v ) |
$order->update_meta_data( '_key', $v ); $order->save(); |
new WP_Query( [ 'post_type' => 'shop_order' ] ) |
wc_get_orders( [ ... ] ) |
The full table, hooks and meta boxes included, is in the audit section.
Why WP_Query and get_post_meta break on orders
WP_Query reads wp_posts. On HPOS, orders live in wp_wc_orders, and all that is left in wp_posts is a record of type shop_order_placehold - an ID reservation with no metadata and no status in any WooCommerce sense. A query with post_type => 'shop_order' returns an empty array. A query with post_type => 'shop_order_placehold' returns hollow shells you cannot do anything with.
get_post_meta( $order_id, ... ) reads wp_postmeta. On HPOS, order metadata lives in wp_wc_orders_meta. In compatibility mode wp_postmeta may well be populated, because WooCommerce writes a copy there on every save, so the snippet "works". It is reading the copy, though, not the source. And since WooCommerce 10.7 a write done the old way through update_post_meta() no longer flows back into HPOS, because sync on read was disabled. The data drifts apart quietly.
Direct $wpdb queries against wp_posts and wp_postmeta have the same problem, plus they break with every schema change. The WooCommerce documentation states plainly that plugin and theme authors should not write their own queries or raw SQL against orders, because changes in the WordPress and WooCommerce database break that kind of code.
None of this is news from 2023. The WC_Order object and the wc_get_order() function have existed since WooCommerce 3.0, April 2017. HPOS only turned "you should" into "you must". Code written against CRUD since 2017 moved to HPOS without a single change.
A diagnostic rule of thumb: if a snippet works on staging without HPOS and "does not work" on production with HPOS, you will almost always find get_post, get_post_meta or WP_Query inside it.
wc_get_order and wc_get_orders in practice
wc_get_orders( $args ) is shorthand for WC_Order_Query. Both take the same arguments and return an array of WC_Order objects, or an array of IDs with 'return' => 'ids'. Below are the queries people look for most often. Every snippet is complete.
A single order
$order = wc_get_order( $order_id );
if ( ! $order ) {
return; // no such order, or the ID is not an order at all
}
$status = $order->get_status(); // 'processing', without the wc- prefix
$total = $order->get_total();
$email = $order->get_billing_email();
$date = $order->get_date_created(); // WC_DateTime or null
$items = $order->get_items();
wc_get_order() returns false for an ID that does not exist and for an ID that is not an order. Always check it. And never $order->ID or $order->post_status: the first does not exist on HPOS, the second was never public API. Use get_id() and get_status() instead.
Recent orders with a given status
$orders = wc_get_orders(
array(
'status' => array( 'processing', 'on-hold' ),
'limit' => 20,
'orderby' => 'date',
'order' => 'DESC',
)
);
The status parameter accepts statuses with and without the wc- prefix. Left out, limit falls back to posts_per_page from the reading settings, usually 10. A value of -1 means all orders, and here is the catch: -1 with the default 'return' => 'objects' loads a hundred thousand objects into memory in a store with a hundred thousand orders. For bulk operations fetch IDs only and process them in batches:
$page = 1;
do {
$result = wc_get_orders(
array(
'status' => 'completed',
'limit' => 500,
'paged' => $page,
'return' => 'ids',
'paginate' => true,
)
);
foreach ( $result->orders as $order_id ) {
$order = wc_get_order( $order_id );
// processing
}
$page++;
} while ( $page <= $result->max_num_pages );
A customer's orders
// By user ID.
$orders = wc_get_orders( array( 'customer_id' => 12 ) );
// By billing email, guests without an account included.
$orders = wc_get_orders( array( 'customer' => 'john@example.com' ) );
The customer parameter takes an ID or an email address, which covers the usual "show this guest's order history".
Orders in a date range
// Created in the first quarter (dates in store timezone, through end of day).
$orders = wc_get_orders( array( 'date_created' => '2026-01-01...2026-03-31' ) );
// Paid after 1 June.
$orders = wc_get_orders( array( 'date_paid' => '>2026-06-01' ) );
// Created within the last hour (timestamp = UTC, precision down to the second).
$orders = wc_get_orders( array( 'date_created' => '>' . ( time() - HOUR_IN_SECONDS ) ) );
Dates in YYYY-MM-DD format are interpreted in the store's timezone, timestamps in UTC. On a "yesterday" report that is a one or two hour difference at the day boundary.
Orders containing a given product
wc_get_orders() has no product_id parameter. There are two honest routes.
The first one is simple and slow once order counts grow: narrow the query by date and walk the line items.
$product_id = 123;
$found = array();
$orders = wc_get_orders(
array(
'date_created' => '>' . ( time() - 30 * DAY_IN_SECONDS ),
'limit' => -1,
'return' => 'ids',
)
);
foreach ( $orders as $order_id ) {
$order = wc_get_order( $order_id );
foreach ( $order->get_items() as $item ) {
if ( $product_id === $item->get_product_id() || $product_id === $item->get_variation_id() ) {
$found[] = $order_id;
break;
}
}
}
The second one is a custom query parameter handled through WooCommerce filters. I cover that pattern in the OrderUtil section, because it needs two code paths. A third option worth knowing about: the order item tables (wp_woocommerce_order_items and wp_woocommerce_order_itemmeta) did not change with HPOS and are shared by both storages, so custom SQL with $wpdb->prepare() is technically fine there.
Refunds
$refunds = wc_get_orders(
array(
'type' => 'shop_order_refund',
'date_created' => '>' . ( time() - DAY_IN_SECONDS ),
)
);
A refund is a separate object, and its parent is the order ID.
Order metadata: get_meta, update_meta_data and save

The pattern from the HPOS recipe book:
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$order->update_meta_data( '_invoice_number', 'INV/2026/09/0142' );
$order->add_meta_data( '_integration_log', 'sent', true );
$order->delete_meta_data( '_temporary_token' );
$order->save();
$number = $order->get_meta( '_invoice_number' ); // a single value
$logs = $order->get_meta( '_integration_log', false ); // an array of all values
WooCommerce decides on its own whether to write to wp_wc_orders_meta or to wp_postmeta. Three traps.
save() is expensive, and the documentation says so outright. One save() at the end of the flow, not after every update_meta_data(). Inside the woocommerce_checkout_create_order hook you get the object before its first save: set the metadata and do not call save() at all, WooCommerce will do it a moment later.
Keys starting with an underscore are "protected": they do not show up in the custom fields meta box. That is the same behaviour as with regular posts, and it still applies under HPOS.
Do not mix the two APIs in one request. After update_post_meta(), a WC_Order object loaded earlier in the same request still holds the old value in memory. If one piece of code in the store writes the old way and another reads through get_meta(), you will get two different results for the same order.
There is also a pattern for reading metadata without loading the whole object, useful across thousands of orders: check the storage with OrderUtil and query wp_wc_orders_meta or get_post_meta() directly. Treat it as an optimisation for a specific case, not as the default route. By default, wc_get_order()->get_meta().
Querying by metadata and fields: meta_query, field_query, date_query
Since WooCommerce 8.2, wc_get_orders() accepts three arguments whose syntax is identical to WP_Query, documented on a page of their own.
meta_query, for example "orders that do not have an invoice number yet":
$orders = wc_get_orders(
array(
'status' => 'completed',
'limit' => -1,
'return' => 'ids',
'meta_query' => array(
array(
'key' => '_invoice_number',
'compare' => 'NOT EXISTS',
),
),
)
);
field_query, meaning operators and nesting on order fields. Previously that required custom SQL:
$orders = wc_get_orders(
array(
'field_query' => array(
'relation' => 'OR',
array(
'field' => 'total',
'value' => 500,
'compare' => '>',
'type' => 'NUMERIC',
),
array(
'field' => 'shipping_total',
'value' => 0,
'compare' => '=',
'type' => 'NUMERIC',
),
),
)
);
date_query, for example orders paid within the last month and created before noon:
$orders = wc_get_orders(
array(
'date_query' => array(
'relation' => 'AND',
array(
'column' => 'date_created_gmt',
'hour' => 12,
'compare' => '<',
),
array(
'column' => 'date_paid_gmt',
'after' => '1 month ago',
),
),
)
);
For a simple comparison on a single field, do not reach for field_query. Order fields are available as top-level arguments, 'billing_city' => 'Berlin' for instance. field_query is for operators and nesting.
The trap: this only works on HPOS
The documentation carries the same box next to each of those three arguments: support is available only when HPOS is the configured order storage. It does not explain the consequences, so I checked in the code what happens on the posts storage.
Two things at once. First, since version 9.2.0 WC_Order_Data_Store_CPT::query() calls wc_doing_it_wrong() with the message "Order query argument (meta_query) is not supported on the current order datastore". That notice is visible only with WP_DEBUG enabled. On production you see nothing. Second, notice aside, the method that builds the arguments for WP_Query has an explicit condition skipping the meta_query key. Your meta_query is dropped from the query, and field_query reaches WP_Query as an unknown argument and is ignored.
The effect: the snippet "works", it just returns the full, unfiltered list. The code above, meant to find orders without an invoice, returns every completed order on the posts storage.
It is the mirror image of the WP_Query problem. The old API breaks on HPOS. The new API breaks on the posts storage. Which is why OrderUtil exists.
OrderUtil: code that has to work on both storages

When you need it: a custom plugin distributed to many stores, a snippet in a store that has not moved to HPOS yet but will, reporting code with custom SQL. For plain CRUD (wc_get_order(), get_meta(), save()) checking the storage is pointless. You check it when you write SQL, when you use meta_query or field_query, or when you register a meta box.
Checking the storage:
use Automattic\WooCommerce\Utilities\OrderUtil;
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
// The store uses HPOS: wp_wc_orders, meta_query and field_query available.
} else {
// Posts storage: wp_posts and wp_postmeta.
}
Checking whether an ID is an order. The classic mistake is 'shop_order' === get_post_type( $id ) inside save_post hooks, which do not fire for orders on HPOS at all:
use Automattic\WooCommerce\Utilities\OrderUtil;
if ( OrderUtil::is_order( $id, wc_get_order_types() ) ) {
$type = OrderUtil::get_order_type( $id ); // 'shop_order' or 'shop_order_refund'
}
A meta box on the order screen. This is the most common source of "my meta box disappeared after HPOS": the order edit screen is no longer a post screen, so add_meta_box() with the shop_order screen shows nothing.
use Automattic\WooCommerce\Utilities\OrderUtil;
add_action( 'add_meta_boxes', function () {
$screen = OrderUtil::custom_orders_table_usage_is_enabled()
? wc_get_page_screen_id( 'shop-order' )
: 'shop_order';
add_meta_box( 'msm_invoice', 'Invoice', 'msm_render_invoice_metabox', $screen, 'side', 'high' );
} );
function msm_render_invoice_metabox( $post_or_order ) {
$order = ( $post_or_order instanceof WP_Post ) ? wc_get_order( $post_or_order->ID ) : $post_or_order;
if ( ! $order ) {
return;
}
echo esc_html( $order->get_meta( '_invoice_number' ) );
}
The callback receives a WP_Post on the posts storage and a WC_Order on HPOS. Rather than branching on every line, fetch the order object straight away and work only with that.
A custom query parameter for both storages. This pattern closes the meta_query gap on the posts storage: on HPOS you translate your parameter into meta_query, and on the posts storage into meta_query for the WP_Query that runs inside WooCommerce. That is legitimate, because the data store does it, not your code.
use Automattic\WooCommerce\Utilities\OrderUtil;
// Usage: wc_get_orders( [ 'invoice_number' => 'INV/2026/09/0142' ] ).
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
add_filter( 'woocommerce_order_query_args', function ( $query_args ) {
if ( ! empty( $query_args['invoice_number'] ) ) {
$query_args['meta_query'] = $query_args['meta_query'] ?? array();
$query_args['meta_query'][] = array(
'key' => '_invoice_number',
'value' => $query_args['invoice_number'],
);
unset( $query_args['invoice_number'] );
}
return $query_args;
} );
} else {
add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', function ( $query, $query_vars ) {
if ( ! empty( $query_vars['invoice_number'] ) ) {
$query['meta_query'][] = array(
'key' => '_invoice_number',
'value' => $query_vars['invoice_number'],
);
}
return $query;
}, 10, 2 );
}
If you are writing a plugin, declare HPOS compatibility with FeaturesUtil::declare_compatibility(). The snippet, and an explanation of what that declaration changes in the admin, are in the HPOS post, in the section on checking whether the store is ready.
How to audit and rewrite old snippets
Step 1: find the suspect places. The recipe book gives a regular expression for searching your code; below is a version trimmed down to what actually concerns orders in practice.
grep -rnE 'wpdb|get_post\(|get_post_field|get_post_status|get_post_type|get_posts|get_post_meta|update_post_meta|add_post_meta|delete_post_meta|wp_insert_post|wp_update_post|wp_delete_post|shop_order' \
wp-content/themes/your-child-theme \
wp-content/plugins/your-plugin
There will be plenty of false positives: products, pages, coupons. You only review the ones that touch orders.
Step 2: replace according to the table.
| The old way | HPOS-compatible |
|---|---|
get_post( $order_id ) |
wc_get_order( $order_id ) |
$order->ID, $post->post_status |
$order->get_id(), $order->get_status() |
get_post_meta( $id, '_k', true ) |
$order->get_meta( '_k' ) |
update_post_meta( $id, '_k', $v ) |
$order->update_meta_data( '_k', $v ); $order->save(); |
delete_post_meta( $id, '_k' ) |
$order->delete_meta_data( '_k' ); $order->save(); |
new WP_Query( [ 'post_type' => 'shop_order' ] ), get_posts() |
wc_get_orders( [ ... ] ) |
'shop_order' === get_post_type( $id ) |
OrderUtil::is_order( $id, wc_get_order_types() ) |
add_action( 'save_post_shop_order', ... ) |
woocommerce_update_order + woocommerce_new_order |
add_meta_box( ..., 'shop_order', ... ) |
add_meta_box( ..., wc_get_page_screen_id( 'shop-order' ), ... ) |
$wpdb->get_results( "... FROM wp_posts WHERE post_type = 'shop_order' ..." ) |
wc_get_orders() with field_query or meta_query; $wpdb on wp_wc_orders only behind OrderUtil |
wp_delete_post( $order_id, true ) |
$order->delete( true ) |
The hooks row needs a comment, because after meta boxes it is the second most common reason for "it stopped working after HPOS". woocommerce_update_order( $order_id, $order ) is called in both data stores, so it works regardless of the storage. Three caveats. When an order moves from a draft status (auto-draft, draft, checkout-draft) to a real one, WooCommerce fires woocommerce_new_order instead of woocommerce_update_order, which is why the replacement for save_post_shop_order is the pair of both hooks. Trashing and restoring have their own hooks (woocommerce_trash_order, woocommerce_untrash_order). In the block checkout, woocommerce_update_order can fire several times for a single order, so the callback has to be idempotent. If you need "always after a save", changes or not, there is also woocommerce_after_order_object_save.
Step 3: test in the target state, meaning on staging with HPOS enabled and compatibility mode off. After 10.7 that is the state the store is heading for anyway. If the code ships to many stores, do a second pass on the posts storage. Once you have tested writes, run wp wc hpos verify_data; I covered the WP-CLI commands in the HPOS post.
Step 4: only now turn off compatibility mode on production, if it was on as a safety net.
One practical note. When the snippet sits inside somebody else's plugin, do not patch its code. The patch disappears with the first update. Report it to the author or look for a replacement. If you are taking over a store from another developer and you do not know what is in functions.php, this audit is the first step before you switch anything; I wrote about that in the post on rebuilding a store without losing SEO and order history.
When NOT to
Do not rewrite code that works on products, coupons and pages. HPOS covers orders and refunds. WP_Query and get_post_meta() on a product are still correct. A common overcorrection is "fixing" get_post_meta() in product code, which then stops working.
Do not rewrite SQL against the order item tables. wp_woocommerce_order_items and wp_woocommerce_order_itemmeta did not change and are shared by both storages.
Do not assume "always CRUD" means "always fastest". A report across hundreds of thousands of orders through wc_get_orders() with full objects will be slow. There, IDs and batches work better, as does deliberate SQL on wp_wc_orders behind OrderUtil, or WooCommerce Analytics with its lookup tables. If the database is the bottleneck for the whole store, that is a separate topic, covered in the post on the slow WooCommerce store.
Do not add OrderUtil and dual code paths to code that will run in one store, on HPOS permanently, with compatibility mode off. CRUD is enough.
Do not use meta_query or field_query in code that may land on the posts storage, in a distributed plugin for instance. Use a custom parameter through the two filters instead, as in the OrderUtil section.
And do not run the audit "on paper". A store with compatibility mode on, where everything "works", proves nothing. After 10.7 the data drift is silent. Audit with grep and verify_data instead of "works on my machine".
Related posts in this series
- WooCommerce HPOS: what it is, whether to enable it and how to migrate without losing orders
- Why a WooCommerce store is slow: the most common causes
- Rebuilding a WooCommerce store without losing SEO and order history
If you would rather not walk through the audit of your own code and plugin list yourself, book a free consultation.
FAQ
Yes, on both storages. The only difference concerns the `meta_query`, `field_query` and `date_query` arguments, which work on HPOS only. On the posts storage they are silently stripped from the query, and the only trace is a `doing_it_wrong` notice with `WP_DEBUG` enabled.
`wc_get_orders()` is shorthand for `WC_Order_Query::get_orders()`. They take the same arguments. The class is more convenient when you build the query step by step through `set()`.
Through a custom query parameter handled by the `woocommerce_order_data_store_cpt_get_orders_query` filter, which translates it into `meta_query` for the `WP_Query` that runs inside WooCommerce. The snippet is in the `OrderUtil` section.
Because on HPOS order metadata lives in `wp_wc_orders_meta`, while `get_post_meta()` reads `wp_postmeta`. Use `wc_get_order( $id )->get_meta( '_key' )`.
It writes to `wp_postmeta`, but since WooCommerce 10.7 that change no longer flows back into the HPOS tables, because sync on read is off. The store reads from HPOS, so the write is invisible. Details are in the HPOS post.
`Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled()`.
`wc_get_orders()` has no such parameter. Narrow the query by date and walk `get_items()`, or add a custom parameter through filters. The order item tables are shared by both storages, so custom SQL with `prepare()` is also correct there.
Because an order is no longer a post. The replacement is the pair `woocommerce_update_order` and `woocommerce_new_order`, which fire on both storages.
As many as `posts_per_page` in the reading settings, usually 10. A value of `-1` returns all of them, which eats memory in a large store with full objects; in that case use `'return' => 'ids'` and batches.