Merchgain
EN
Available now
Next, in this order
  • DeutschDE
  • EspañolES
  • FrançaisFR
  • ItalianoIT
  • PortuguêsPT
  • NederlandsNL

A language shows up here once its pages are actually written. Nothing on this site is machine-translated.

Install freeInstall

Three things WooCommerce does with your cost data that nobody tells you

A cost of zero is not stored. A refund rewrites the recorded cost of every line on the order. Switching the feature off can delete cost history on the next save. All three are real, all three are in core, and all three come from the same decision.

Updated 2026-09-06

WooCommerce 10.3 brought Cost of Goods Sold into core, and with it something the platform never had: a cost recorded on the order line, at the moment of sale. That is the part worth having. An order from March keeps saying what the goods cost in March, even after two supplier price rises.

Three behaviours around that field surprise people who build on it. They are not bugs in the sense of code doing something its author did not intend - each has a defensible reason. They are surprises, and a surprise you discover from a customer’s email is more expensive than one you read about first.

Everything below was read in the WooCommerce source, version 11.0.1, in September 2026. Function and file names are given so you can check rather than take our word for it.

One line of code explains all three

When order item data is saved, the cost passes through save_cogs_data() in abstract-wc-order-item-type-data-store.php:

$cogs_value = (float) $cogs_value;
if ( 0.0 === $cogs_value ) {
    $this->order_item_data_store->delete_metadata( $item->get_id(), '_cogs_value', '' );
} else {
    $this->order_item_data_store->update_metadata( $item->get_id(), '_cogs_value', $cogs_value );
}

Read that again with one thought in mind: zero is not a value here, zero is a delete.

Hold onto it. The other two traps are this same line reached by two different routes - one through a refund, one through a settings checkbox. Understand this branch and you can predict both.

Trap 1 - a cost of zero and no cost at all are the same thing

The direct consequence. A product that genuinely costs you nothing - a free sample, a giveaway mug, a downloadable extra - is stored on the order line exactly the way a product nobody ever costed is stored: as nothing at all.

There is no flag, no zero, no marker to say which of the two it was. And a product with no cost entered returns 0 from get_cogs_total_value(), so both routes arrive at the same delete.

This has a good side. Because absence really does mean we don’t know, a report can honestly say what share of revenue has a cost behind it instead of quietly treating unknowns as free. Coverage is a real number, not an estimate.

It also has a permanent nuisance. Your genuine freebies will sit on the missing-cost list forever, and no plugin can lift them off it, because the information that would distinguish them was never written down.

One asymmetry is worth knowing, because it decides how any such list should be built. On the product, a cost of zero is stored - the product data store only deletes empty strings and empty arrays, not zeroes. So at product level, zero and missing can be told apart; at order-line level they cannot. A “products with no cost” list therefore has to be read from the product, while coverage has to be read from the order lines. Two different sources of truth, for one honest reason. More on zero versus missing.

Trap 2 - a refund rewrites the recorded cost of the whole order

This is the one that changes numbers you already reported, and it is the reason to read this page before building a spreadsheet on this data.

At the end of wc_create_refund() in wc-order-functions.php:

if ( ...->feature_is_enabled() && $order->has_cogs() ) {
    $order->calculate_cogs_total_value();   // <- the PARENT order
}
$order->save();

That call is not scoped to the refunded lines. calculate_cogs_total_value_core() in abstract-wc-order.php walks every item on the parent order and calls $item->calculate_cogs_value(), which reads the cost from the product as it stands today. Then save() writes all loaded items back.

So refunding one item on a twelve-line order recalculates the recorded cost of all twelve, from today’s product costs. If a supplier price changed in the meantime, the history changes with it. March stops saying what March cost.

Now combine it with trap 1. If the product’s cost was cleared at some point, the recalculation produces 0, and 0 deletes. The order’s cost history is not merely restated - it is gone, and there is nothing to restore it from.

A refund is not an edge case. It is a Tuesday. What this looks like in a report.

Trap 3 - switching the feature off can destroy data on the next save

Measured on WooCommerce 10.3.0: unticking the Cost of Goods Sold checkbox deletes nothing by itself. That is worth saying plainly, because it is the opposite of what people fear.

The risk is what happens next. With the feature disabled, get_cogs_total_value() and calculate_cogs_value() return 0 and false. And zero, by trap 1, deletes. So any subsequent write that touches an order’s items - editing an old order in the admin, issuing a refund against it, an update through the REST API - runs the recalculation, gets zero, and clears the stored cost of those lines.

The order in which those two facts land matters. “Switching it off is safe” and “touching an order afterwards is not” are both true, and only the pair of them is useful. The full sequence.

Why core does this, in fairness

None of the three is careless.

Deleting zeroes keeps the order item meta table from filling with rows that carry no information - and in a table that grows with every line of every order, that is a real consideration, not a micro-optimisation.

Recalculating on refund is the consistent choice if you regard the order total as something that should agree with current product data after it is modified. The alternative - refunds that leave a partly stale cost total - is defensible too, but it is not obviously more correct.

Returning zero when the feature is off is what a disabled feature should do everywhere else in WooCommerce. It only becomes destructive in combination with the first decision.

The cost of all three lands in one specific place: on anyone who treats the order line as an immutable historical record. Core never promised that it was. It is just very easy to assume, because that is exactly what a cost snapshot looks like.

If you are building on this data

  • Treat a missing value as unknown, never as zero. The database cannot tell you which it was, so a report that assumes zero is inventing profit.
  • Show coverage next to every number. “Cost is known for 74% of revenue” is the difference between a figure someone can act on and a precise-looking figure that is quietly wrong by a quarter.
  • Read “no cost” from the product and coverage from the order lines. Trap 1 makes any other combination produce false alarms.
  • Export monthly. A CSV taken at month end is the only record of your cost history that a refund cannot rewrite. This is the single most useful habit on this page.
  • Do not promise immutable cost history unless you store it yourself. Core does not provide it, and building it means writing to the store rather than reading from it - a different level of responsibility, and a decision to make deliberately.

Checking this yourself

Everything here is readable in the WooCommerce source: save_cogs_data() in includes/data-stores/abstract-wc-order-item-type-data-store.php, wc_create_refund() in includes/wc-order-functions.php, and calculate_cogs_total_value_core() in includes/abstracts/abstract-wc-order.php. Version 11.0.1, read in September 2026.

If any of it behaves differently in your store or in a later release, say so - these pages are corrected when they turn out to be wrong, and a dated correction is more useful to everyone than a page that quietly stays out of date. How the same three facts shape the numbers in our own report is set out in how profit is calculated.