What the WooCommerce HPOS Migration Actually Breaks on an Indian GST Build

High-Performance Order Storage sounds like a schema swap you tick a box for. On an Indian GST build with invoice counters, place-of-supply meta, and a Razorpay webhook that reaches into postmeta, it is not. Here is what broke on a 2.1 lakh-order migration, what we patched, and why we now wait a full filing cycle before flipping the switch.

18 October 2026·9 min read·Ritwik Bhattacharya

The wp_postmeta table on this particular store had 11.4 million rows, and roughly 60% of them were _order_* keys that HPOS was about to render homeless. The compatibility mode toggle in the WooCommerce settings makes this sound like a non-event. It is not a non-event when four of your plugins read order meta via get_post_meta() directly, and two of those four are the ones producing the tax invoice that goes to the buyer and, eventually, to the CA.

We ran the migration on a Sunday. The reasoning, such as it was: nobody files GSTR-1 on a Sunday. Turned out the client's CA had been clearing a backlog over the weekend and emailed on Monday morning asking why three invoices from the previous week had blank place-of-supply fields. More on those three invoices in a bit.

The WooCommerce admin paints compatibility mode as a dual-write setup — HPOS becomes the authoritative source, postmeta gets kept in sync, and your old code that still reads from postmeta keeps working. Which is true, as far as it goes. The part that nobody flags in the migration wizard is the direction of the sync and the latency of it. Writes to wc_orders and wc_orders_meta are pushed back to postmeta on a hook. Writes that go directly to postmeta — which is what the WebToffee GST plugin was doing for its place-of-supply field, because that's how it was written in 2021 and nobody rewrote it when HPOS landed — do get synced back the other way, but the sync is not instant and it is not transactional. If HPOS is the authority and your plugin writes to postmeta, there is a window where the invoice generator (reading from HPOS) sees nothing, generates the PDF, and emails it out before the sync catches up.

This is the integration-contract problem, and it is the whole post really. HPOS isn't a schema change you absorb silently. It's a change in which table is the source of truth, and every plugin on your store that touches order data has an implicit contract with that choice. If the contract was "I write to postmeta and I read from postmeta", flipping HPOS on breaks the contract on the read side quietly and on the write side even more quietly. Compatibility mode papers over the read side most of the time. It does nothing for the race on the write side. And the race is where the invoices go wrong.

What we found when we actually ran the sync on a 2.1 lakh-order database

2.1 lakh orders. 11.4 million postmeta rows. Cloudways Vultr HF, 4 vCPU, 8 GB RAM — nothing fancy. The sync itself took 4 hours 20 minutes and completed without a single row-level error in the WooCommerce log. On paper, a textbook migration.

On 7 August the HPOS sync completed cleanly in staging. We ran the usual checks — spot-checked fifty random orders across a two-year range, verified that wc_get_orders() returned the same counts with and without the HPOS flag, confirmed the PDF invoices generated for a sample of recent orders matched byte-for-byte with pre-migration output. All green. We flipped production the same evening after a backup.

By the 11th we noticed three invoices in production had blank place_of_supply fields. All three were orders placed through a flow where our in-house place-of-supply extension writes the field to postmeta on woocommerce_checkout_update_order_meta, and the WebToffee GST plugin reads it on woocommerce_order_status_processing to stamp the invoice. Post-migration, WebToffee was reading from wc_orders_meta (because it had been updated for HPOS compatibility in a release we hadn't tracked), while our custom extension was still writing to postmeta. The sync from postmeta → HPOS runs on a scheduled action; the invoice generation runs on a status transition. The status transition fired first on these three. Blank field. PDF went out. Buyer paid. CA noticed.

That's one failure mode. The other one we hit the same week was the Razorpay webhook handler — the one the previous developer had written, which looks up the order by razorpay_payment_id via a meta_query on wc_get_orders(). Post-HPOS, meta_query against custom keys that aren't in the HPOS internal keys list doesn't resolve against wc_orders_meta the way you'd expect — it still works, but only if the key has been registered or if compatibility mode is populating postmeta in time. On a webhook that fires within milliseconds of payment capture, "in time" is a bet. We saw two cases where the webhook came in, failed to find the order, logged a warning, and gave up. The order sat in pending-payment until a human went looking.

Then the Tally export broke, which was the one we should have anticipated. Our exporter uses wc_get_orders() with a meta_query on a custom flag we set when an order is reconciled. Post-migration, that query silently returned zero results. No error, no warning — zero rows, which the exporter interpreted as "nothing to export this week". The accounts team noticed on the following Thursday when their weekly Tally import file was empty. We hadn't registered the custom flag in wc_orders_meta, so wc_get_orders() was looking in the wrong place and finding nothing.

Related to this — and this is the one that surprised us most — custom fields need an explicit allow-list via woocommerce_rest_api_order_response (and the equivalent for the admin UI) or they vanish from the order edit screen entirely. Not deleted. Just invisible. The data is still in postmeta, and if you turn compatibility mode off it stays in postmeta, orphaned. The admin UI reads from wc_orders_meta, which only shows internal keys plus whatever you've explicitly registered. We had about a dozen custom keys across the store — order source, GST treatment flag, B2B/B2C marker, the place-of-supply override, a few others — and all of them had disappeared from the order detail view until we hooked into the right filters.

The one thing that genuinely did get faster, credit where it's due: the admin orders list page. The query that powers it was previously doing a join nightmare across postmeta for sort and filter; post-HPOS it's a straight indexed query against wc_orders. The page went from about 8 seconds to under a second on this store. And yes, that pitch is accurate — the speedup is real and the client's ops team noticed inside a day. The thing nobody mentions in the migration notice is the plugin-audit tax you pay to get there.

The GST-specific plugins we had to patch, fork, or replace

Three plugins and one in-house extension. In rough order of pain:

WebToffee GST was the easiest, because the maintainers had been shipping HPOS-aware updates for a while. The version on this client's store was six months old. We updated, re-tested, and the plugin itself was fine — the breakage was on the handoff to our custom extension, not inside WebToffee. If you're running a current WebToffee build and you don't have custom code writing to the same meta keys, you probably won't see the issue we saw. Probably.

WooCommerce PDF Invoices & Packing Slips (the Ewout Fernhout one) needed a settings re-save and nothing else. The plugin reads order data through the WC_Order abstraction, so it inherits HPOS-correctness for free. The settings re-save was because the template path had been overridden by the previous developer in a way that cached the old data layer; clearing it fixed the generation.

Our in-house place-of-supply extension — the one we built two years ago when the WebToffee field didn't quite handle the SEZ edge cases this client needed — had to be rewritten. Not patched. Rewritten. Every update_post_meta and get_post_meta call became $order->update_meta_data() and $order->get_meta(), with the extension registering its keys through woocommerce_order_data_store_cpt_get_orders_query and the HPOS equivalent so the meta_query lookups kept working. Took us about two days. We also added an explicit sync hook on woocommerce_update_order so the field is written through the HPOS data layer rather than straight to the table.

The Razorpay webhook handler we rewrote to look up orders by the _transaction_id field (which HPOS treats as a first-class column on wc_orders) rather than by the custom razorpay_payment_id meta. Faster, cleaner — and crucially no dependency on compatibility mode staying on. Should have been that way from the start — the only reason it wasn't is that the original developer wrote it before Razorpay's plugin started populating _transaction_id consistently. (Or maybe it always did and we're wrong about the history — we didn't dig that far.)

The Tally exporter — we registered the custom reconciliation flag through the HPOS meta registration hook, and the query started returning rows again. Simple fix once you know. We also added a sanity assertion: if the weekly export has fewer than 10 rows, send an alert instead of a file.

What we ended up doing operationally, after all of this, is running both storage modes in compatibility sync for one full GST filing cycle — minimum 35 days, so the whole arc of GSTR-1 and GSTR-3B for a month is observed with HPOS as the authority but postmeta still populated as a shadow. If a discrepancy shows up at filing time, we have both tables to reconcile from. Only after that cycle do we disable the sync and let HPOS be the sole source. On this client we actually went two cycles because the first month's filing had a credit-note edge case we wanted to see cleanly pass through before cutting the safety net.

We still don't have a clean answer for stores that use WooCommerce Subscriptions. The renewal order creation path writes to both tables in ways we haven't fully traced yet — there seems to be a window where the parent subscription metadata and the renewal order metadata are briefly inconsistent, and we've seen one case where a renewal invoice generated with a stale GSTIN — we think customer-side, didn't fully trace it before rolling back. We rolled that one back and haven't attempted HPOS on a Subscriptions store since. If anyone has run HPOS cleanly on a Subscriptions-based WooCommerce build for a full year, including through a GSTIN change on the customer side, we'd genuinely like to hear how it held up at renewal time.

Ritwik Bhattacharya

Written by

Ritwik Bhattacharya

Principal Engineer

Eleven years in front-end engineering, based in Gurgaon. Builds in Next.js and Node, reluctantly maintains the WordPress installs we inherit from older clients. Writes here about the engineering side of the practice — frameworks, performance budgets, and the bugs that keep coming back (usually hydration).

                    

Ready to build something that works?

Get a free website mockup for your business. No commitment, no fluff — just what it could look like.

Call us — picked up by a human+91 88820 82228WhatsApp us — replies in minutes+91 88820 82228