How WooCommerce Handles Sale Prices Internally (And Why Discount Plugins Work Differently)
WooCommerce Internals
How WooCommerce Handles Sale Prices Internally (And Why Discount Plugins Work Differently)
WooCommerce stores sale prices as database records. Most discount plugins bypass that entirely, applying prices at request time through PHP filter hooks. Both approaches get the job done — but the architectural difference has real consequences for sale badges, shop filters, and catalog queries.
When you run a sale in WooCommerce, you expect the discounted price to show on product pages, the “Sale!” badge to appear, and discounted products to turn up when customers filter by “On Sale.” And if you set prices manually through the product editor, all three of those things work exactly as expected.
But most stores eventually move to a discount plugin for time-limited promotions, volume pricing, or BOGO deals — and something surprising happens. The badge is there. The price is right. But the On Sale filter? Empty.
The reason is a genuine architectural difference between how WooCommerce’s native sale price system works and how most discount plugins apply pricing. Understanding that difference isn’t just trivia. It helps you predict what your store will and won’t do, avoid surprises during a promotion, and make informed decisions about your setup.
How WooCommerce stores sale prices natively
WooCommerce’s native sale price system is straightforward: when you fill in the Sale price field on a product edit page, WooCommerce writes that value directly to the database. Specifically, it saves it as a postmeta record under the key _sale_price, alongside the regular price stored as _regular_price.
When the product is later loaded — on a shop page, product page, or category archive — WooCommerce reads those fields from the database. The sale price is already there, at rest, waiting. Nothing needs to calculate it; nothing needs to intercept the request. The product object just has a sale_price value, and WooCommerce uses it.
The same pattern applies to WooCommerce’s scheduled sales. When you set a sale date range on a product, WooCommerce stores the sale price alongside the schedule. A WordPress cron event (woocommerce_scheduled_sales) activates and deactivates the sale price when the dates arrive. The underlying mechanism is still a database write — the cron event updates _sale_price and the _price postmeta field when the sale starts, and clears them when the sale ends.
The native model is database-first
Native WooCommerce sale pricing is a write-then-read model. The discount is committed to the database when the sale is set up. The storefront then reads that value at display time. This makes the discount visible to any system that queries the database — not just the product page.
This write-then-read architecture has a clear advantage: it’s the simplest possible model. Any code that can query a product’s postmeta can see whether it’s on sale. No hooks need to fire. No context needs to be available. The sale price is just data.
It also has a clear limitation: it only works for static, pre-calculated prices. A 20% off sale on a simple product has a fixed discounted price that can be stored. A BOGO deal where the free product depends on what’s in the cart does not. That context-dependent class of discount — which covers tiered pricing, cart-total thresholds, BOGO, and bundles — cannot be expressed as a single number written to the database in advance. Those prices only exist once you know the cart state.
How is_on_sale() decides whether a product is on sale
WooCommerce’s sale badge and strikethrough pricing are both driven by a single method: $product->is_on_sale(). Understanding what this method does — and what it responds to — is the key to understanding how discount plugins produce (or fail to produce) sale badges.
Internally, is_on_sale() calls get_sale_price() and checks whether the result is a non-empty value that’s less than the regular price. If so, it returns true. The theme then renders the sale badge and strikethrough.
The critical detail is that get_sale_price() is filterable. It fires the woocommerce_product_get_sale_price WordPress filter before returning. Any plugin registered on that filter gets a chance to intercept the call and return a different value.
This means there are two paths to a sale badge:
- A stored
_sale_pricevalue in the database (the native path). - A plugin registered on
woocommerce_product_get_sale_pricethat returns a discounted value at render time (the filter path).
Both produce the same result from the theme’s perspective: is_on_sale() returns true, the badge renders, strikethrough pricing appears. The badge does not know or care which path was used.
How the On Sale catalog filter works (and why it’s different)
This is where the two architectures diverge in a way that catches a lot of store owners off guard.
WooCommerce’s On Sale catalog feature — which powers the [sale_products] shortcode, the WooCommerce On Sale products block, and third-party shop filters like FacetWP or JetSmartFilters — does not call is_on_sale() for each product. It would be far too slow to load every product and fire its filter hooks just to build a list.
Instead, it uses a database query via wc_get_product_ids_on_sale(). That function reads the wc_product_meta_lookup table, which maintains a pre-computed onsale column. This column is set to 1 when a product has a database-resident _sale_price, and 0 when it doesn’t.
The lookup table is a performance optimization — it lets WooCommerce find all on-sale products in a single indexed query rather than loading every product. But it means the On Sale catalog feature has absolutely no visibility into prices set through PHP filter hooks. Those prices exist in memory during a specific HTTP request. They are never written to the database. The onsale column is never updated. The catalog query never finds those products.
Two different questions, two different systems
“Is this product on sale right now?” and “Which products are on sale right now?” are answered by entirely separate mechanisms. The first uses per-product filter hooks at render time. The second uses a database column. A discount plugin that only hooks filters answers the first question correctly but cannot answer the second.
This is not a bug, and it’s not an oversight in the discount plugins that use this architecture. It is a structural consequence of the two-system design in WooCommerce itself.
How filter-based discount plugins apply prices at runtime
Most WooCommerce discount plugins — including Discount Rules for WooCommerce by Flycart, Advanced Dynamic Pricing for WooCommerce, Advanced Coupons, YITH WooCommerce Dynamic Pricing, and Smart Cycle Discounts — apply prices through WooCommerce’s filter hooks rather than writing to the database.
The typical implementation hooks one or more of the following filters:
woocommerce_product_get_price— the active price for the productwoocommerce_product_get_sale_price— the sale price specifically (whatis_on_sale()reads)woocommerce_product_variation_get_priceandwoocommerce_product_variation_get_sale_price— the variation equivalentswoocommerce_before_calculate_totals— a cart action that lets the plugin adjust prices when cart totals are being calculated
When a product page or shop page loads, the plugin’s filter callback fires, checks whether a discount applies to this product right now, calculates the discounted price, and returns it. WooCommerce uses that returned value for all display purposes — including the is_on_sale() check that drives the sale badge.
When the page request ends, nothing is written to the database. The next page load fires the filter again and recalculates. The product’s stored _sale_price in the database is never touched.
This is the defining characteristic of the runtime filter approach: the discounted price is calculated fresh on each request and lives only in PHP memory for the duration of that request.
How Smart Cycle Discounts specifically works
Smart Cycle Discounts applies discounts entirely through WooCommerce’s runtime filter hooks. The core is WSSCD_WC_Price_Integration, which registers on the following hooks when the plugin initializes:
woocommerce_product_get_priceandwoocommerce_product_get_sale_price(for simple products)woocommerce_product_variation_get_priceandwoocommerce_product_variation_get_sale_price(for variable product variations)woocommerce_get_price_html(for the formatted price HTML shown on product pages)woocommerce_before_calculate_totals(for cart pricing, run at priority 999 so third-party add-ons adjust prices first)
For block cart and checkout (WooCommerce’s modern checkout experience), Smart Cycle Discounts exposes its cart pricing through a Store API extension (WSSCD_WC_Blocks_Integration), which hooks into the same woocommerce_before_calculate_totals action under the hood.
When any of these filter hooks fire, Smart Cycle Discounts checks whether an active campaign applies to the product, calculates the discounted price, and returns it. When no campaign is active, the filter returns the original price unchanged.
Critically: Smart Cycle Discounts does not write to _sale_price postmeta in the database when a campaign activates. It does not update the wc_product_meta_lookup.onsale column. When a campaign ends, there is no cleanup needed — because nothing was ever written. The product records in the database are untouched throughout the entire campaign lifecycle.
Sale badge: works automatically
Because Smart Cycle Discounts hooks woocommerce_product_get_sale_price, the value returned by get_sale_price() is the discounted price when a campaign is active. That makes is_on_sale() return true, which causes WooCommerce’s default “Sale!” badge and strikethrough pricing to render — automatically, on every theme that renders the standard sale flash template hooks, without any extra plugin configuration.
The honest tradeoffs of runtime pricing
The runtime filter approach has genuine advantages, and one genuine limitation that matters in certain store configurations. Both are worth understanding clearly.
What runtime pricing handles well
No bulk database writes on activation. When a campaign activates for 500 products, nothing is written to the database. The discount is live instantly, because there is no data to write. When the campaign ends, nothing needs to be cleaned up. Price restoration is also instant.
Non-destructive to product data. The product’s stored _regular_price, _sale_price, and _price fields are never modified. If a product already has its own native sale price set, Smart Cycle Discounts applies on top of it or alongside it — the existing data is untouched.
Context-dependent pricing is possible. This is the most important architectural advantage. A BOGO deal — where the customer gets the second item free only if the first is in the cart — cannot be expressed as a single stored price. The discount depends on cart state that doesn’t exist until runtime. The same is true of tiered volume discounts (price depends on quantity), cart-total thresholds (discount depends on cart total), and bundle deals (discount depends on which products are in the cart together). Runtime filtering is the only mechanism that can handle all of these uniformly, because it fires during the actual request when all that context is available.
Lightweight on the server. There are no background jobs writing to the database during campaign start or end. No cron-based price transitions. No risk of a cron failure leaving orphaned sale prices behind after a promotion ends.
The one real limitation
Runtime pricing does not update the wc_product_meta_lookup.onsale column, which means products discounted through runtime filters are invisible to WooCommerce’s catalog-level on-sale queries. Concretely:
- The
[sale_products]shortcode will not include them. - The WooCommerce “On Sale Products” block will not include them.
- The
wc_get_product_ids_on_sale()function will not return them. - Third-party shop filters (FacetWP, JetSmartFilters, YITH filters, etc.) that use the “On Sale” query will not find them.
This is not unique to Smart Cycle Discounts. Every runtime filter-based discount plugin shares this limitation, because the limitation is structural — it comes from how WooCommerce’s catalog query system works, not from any individual plugin’s design choices. Discount Rules for WooCommerce by Flycart, Advanced Dynamic Pricing for WooCommerce, and YITH WooCommerce Dynamic Pricing all use the same filter-hook architecture and all have the same On Sale filter behavior.
| What you want to know | Native _sale_price |
Runtime filter plugin |
|---|---|---|
| Discounted price shows on product page | Yes | Yes |
| “Sale!” badge renders on product page | Yes | Yes |
| Strikethrough regular price displays | Yes | Yes |
| Discounted price applies at cart/checkout | Yes | Yes |
Product appears in [sale_products] shortcode |
Yes | No |
| Product appears in On Sale shop filter (FacetWP, etc.) | Yes | No |
| Works for BOGO / tiered / cart-threshold discounts | No | Yes |
| Requires bulk DB writes on campaign start/end | Yes | No |
| Risk of orphaned sale prices if cleanup fails | Yes | No |
Why runtime filtering is the right architecture for complex discounts
It might seem like the database-write approach is simply more capable — you get the On Sale filter working, so why not use it? The answer becomes clear once you consider what a discount plugin actually needs to handle.
A 20% off sale on a single product can be written to the database. The discounted price is a fixed number. But consider a tiered volume discount: 5% off if the customer buys 2–4 units, 10% off for 5–9, 15% off for 10 or more. There is no single “sale price” to write. The discount depends on the quantity in the cart, which doesn’t exist when the campaign is activated.
The same is true for BOGO deals (the discount depends on which products are paired in the cart), spend threshold discounts (the discount depends on cart total), and bundle discounts (the discount depends on the combination of items selected). These prices literally do not exist until runtime. The only place to calculate them is when cart state is known — which is exactly what the filter hooks provide.
The runtime filter architecture is not a compromise or a shortcut to avoid database writes. It is the correct design for discount logic that depends on cart context, because that context does not exist at any earlier point. A database-write plugin that only supports simple percentage or fixed discounts can write prices in advance because those prices are context-independent. Any plugin that supports tiered pricing, BOGO, or spend-threshold discounts must use runtime calculation.
Practical implications for your store
Now that the architecture is clear, here is what it means in practice for the decisions you need to make.
If you want discounted products in your On Sale shop filter
If your store has a “Sale” category, an On Sale filter facet, or uses [sale_products] as a way to surface current promotions, a runtime filter-based discount plugin won’t populate that automatically. Your options are:
- Use WooCommerce’s native sale price field for simple flat discounts on specific products, and accept the manual setup cost.
- Use custom code (a hook on campaign activation) to write the discounted price to
_sale_priceand trigger awc_product_meta_lookupupdate. This effectively combines both approaches — the plugin manages the logic, and you add a database sync on top. It requires developer work and introduces the cleanup problem back into the equation. - Create a separate “Sale” product category or tag, and manually assign discounted products to it during a promotion. This is a workaround rather than a solution, but for stores with a small catalog it may be simpler than either option above.
For a deeper look at the specific filtering behavior and how to work around it, the post on why discounted WooCommerce products don’t show in the On Sale filter covers the full range of options.
If your discount types depend on cart context
If you run BOGO deals, tiered volume pricing, spend-threshold discounts, or bundle promotions, a runtime filter-based plugin is the only architecture that can handle these cleanly. A database-write approach cannot express cart-dependent prices because the cart state doesn’t exist when the price would need to be written.
For this use case, the On Sale filter limitation is unavoidable unless you build a custom sync layer on top of the plugin — and even then, the stored price would be a static approximation of a dynamic value.
If you run simple flat discounts and need On Sale filter behavior
For straightforward percentage or fixed discounts where the discounted price is known in advance, a plugin that offers database-write pricing can give you the full native behavior: badge, strikethrough, and On Sale filter. The tradeoff is slower campaign activation/deactivation (bulk writes) and the risk of orphaned sale prices if something goes wrong during cleanup.
This is the core architectural decision in discount plugin design: runtime flexibility vs. catalog visibility. Neither approach is wrong. They optimize for different things.
The sale badge is not the same as the On Sale filter
This is worth restating plainly because the two are often conflated. With any runtime filter-based discount plugin running an auto-apply campaign, the sale badge does show on product pages. The strikethrough price does show. The discount does apply at cart. What doesn’t work is the catalog-level On Sale query that powers [sale_products] and third-party filter facets. These are two separate systems with two separate data sources, and a runtime plugin satisfies the first but not the second. Understanding which behavior you actually need is the key to choosing the right tool.
The full mechanics of when the sale badge appears and when it doesn’t — including coupons, code-gated campaigns, and theme overrides — are covered in the companion post on WooCommerce discounts that show a sale badge and the ones that don’t.
Frequently asked questions
Does WooCommerce write to _sale_price every time I use a discount plugin?
No. Most WooCommerce discount plugins — including Smart Cycle Discounts, Discount Rules for WooCommerce, Advanced Dynamic Pricing, and YITH Dynamic Pricing — apply prices through PHP filter hooks at runtime. Nothing is written to the product’s _sale_price database field. The only discount mechanism that writes to _sale_price is WooCommerce’s own native sale price field, set manually on the product edit page or activated by a scheduled sale cron event.
Why does the sale badge show on the product page but not in the On Sale shop filter?
The sale badge is driven by $product->is_on_sale(), which fires the woocommerce_product_get_sale_price filter at render time. A runtime discount plugin registered on that filter triggers the badge correctly. The On Sale shop filter uses wc_get_product_ids_on_sale(), which reads the wc_product_meta_lookup.onsale database column — a column that is only set when a product has a stored _sale_price. Runtime plugins never update this column, so the badge and the filter behave differently for the same product. This is structural, not a bug.
Is Smart Cycle Discounts’ runtime approach a limitation or a design choice?
It is a design choice, and one that is shared by essentially every discount plugin that supports cart-dependent discount types. Smart Cycle Discounts supports percentage, fixed, tiered, BOGO, spend-threshold, and bundle discounts. The last four of those require cart context — the discounted price depends on what’s in the cart, not just which product is being viewed. Runtime filtering is the only architecture that can calculate those prices correctly, because the relevant context only exists at request time. The On Sale filter limitation is a direct consequence of using the architecture that makes those discount types possible.
Does this mean Smart Cycle Discounts is slower than a database-write plugin?
For display pricing, there is a small per-request calculation involved. For campaign activation and deactivation, Smart Cycle Discounts is significantly faster — there are no bulk database writes when a campaign starts or ends, regardless of how many products are in the campaign. The performance difference is most noticeable during campaign lifecycle events, not during storefront browsing. For high-traffic stores, the absence of bulk writes during peak promotional periods is a meaningful advantage over database-write plugins.
Can I use WooCommerce native sale prices alongside Smart Cycle Discounts?
Yes. If a product has a native _sale_price set in the database, that value remains in the database untouched when a Smart Cycle Discounts campaign is active. How the final price is calculated depends on the campaign’s “apply to sale items” setting. If the campaign is configured to apply to products already on sale, the Smart Cycle Discounts discount applies on top of the existing sale price. If not, the campaign skips that product. Either way, the native sale price data is never modified or overwritten.
What happens to the sale badge when a Smart Cycle Discounts campaign ends?
The badge disappears immediately. Because the discount lives entirely in PHP memory via filter hooks, when the campaign becomes inactive, the filter stops returning a discounted value. The next page load fires get_sale_price(), the filter returns the original (unmodified) value, is_on_sale() returns false, and no badge renders. There is no database cleanup required, no orphaned sale prices to find, and no caching issue to manage — the product records were never changed.
Key takeaways
- Native WooCommerce sale prices are database records. They are written to
_sale_pricepostmeta when a sale is set, and read back at display time. This makes them visible to every system that queries the database, including the On Sale catalog filter. - Most discount plugins apply prices at runtime through PHP filter hooks. No data is written to the product records. The discounted price is calculated fresh on each request and lives only in PHP memory for that request.
- The “Sale!” badge works for both approaches. Because
is_on_sale()fires thewoocommerce_product_get_sale_pricefilter at render time, a runtime plugin that hooks this filter produces a correct badge — same as native pricing. - The On Sale catalog filter only works for native (database) prices.
wc_get_product_ids_on_sale()reads a pre-computed database column, not filter hook results. Runtime pricing is invisible to it. This is a structural limitation of WooCommerce, not a plugin bug. - Runtime filtering is the necessary architecture for context-dependent discounts. BOGO, tiered, spend-threshold, and bundle discounts depend on cart state. That state doesn’t exist until request time, so a database-write approach cannot calculate these prices in advance.
- Smart Cycle Discounts never writes to
_sale_price. All pricing is handled viawoocommerce_product_get_price,woocommerce_product_get_sale_price, andwoocommerce_before_calculate_totals. Product records are untouched throughout the full campaign lifecycle.