Skip to content
Product Catalog

How to Build a WooCommerce Sale Category That Empties Itself

Making a WooCommerce sale category automatic: why the popular functions.php snippet fails, what to use instead, and how to keep the category a real one.

Most stores eventually want the same thing: a /product-category/sale/ URL that fills itself the moment a price drops and clears itself when the price goes back up. WooCommerce has no setting for this. Search for how to make a WooCommerce sale category automatic and the top result is almost always the same functions.php snippet, hooked to woocommerce_product_query, and its own comment thread is a decade-long queue of people reporting it broken.

The snippet is not merely outdated. It is structurally the wrong shape for the job, and understanding why saves you from re-fixing it every time WooCommerce or PHP moves. This post explains the failure mode, gives you a corrected version anyway if you still want it, and then sets out the three honest ways to get a self-maintaining sale page — with what each one actually costs you.

The short answer#

WooCommerce has no built-in automatic sale category: the On Sale Products collection gives you the listing but no category, and a genuine one requires a rule that writes real product_cat term assignments — the popular wc_get_product_ids_on_sale() snippet only fakes membership at query time.

  • The snippet fakes membership. Hooking woocommerce_product_query and replacing the category constraint with a list of on-sale IDs creates no term relationships, so the category shows a count of zero in wp-admin, cannot be used to filter the products list, and is invisible to feeds and ERP bridges.
  • The empty-array trap. When nothing is on sale, post__in with an empty array returns everything rather than nothing — a WordPress behaviour marked wontfix in ticket #28099 — so the morning a promotion ends the whole catalogue appears at full price under “Sale”.
  • Cached and mistimed data. The on-sale list is a 30-day transient that never sees runtime-computed discounts such as dynamic, role-based or bulk pricing, and scheduled sales only start when the woocommerce_scheduled_sales action next runs rather than at midnight.
  • Display-only options are safe. WooCommerce’s own On Sale Products collection or the [products on_sale=”true”] shortcode is genuinely automatic, needs no code and cannot break, but it gives you no category URL, breadcrumb, menu placement or filter interaction.
  • Only real terms work everywhere. Assigning genuine product_cat terms by rule puts real rows in wp_term_relationships, so admin lists, counts, breadcrumbs, menus, faceted filters and feeds all behave, and when the sale ends the count simply drops to zero.

The snippet everyone finds#

You create an empty product category with the slug sale, assign nothing to it, and add this:

add_action( 'woocommerce_product_query', 'bbloomer_sale_category' );

function bbloomer_sale_category( $q ) {
   if ( "sale" !== $q->get( 'product_cat' ) ) return;
   $q->set( 'post_type', 'product' );
   $q->set( 'product_cat', null );
   $product_ids_on_sale = wc_get_product_ids_on_sale() ? wc_get_product_ids_on_sale() : array();
   $q->set( 'post__in', $product_ids_on_sale );
}

The logic is easy to follow. When the main product query is for the category sale, throw away the category constraint and replace it with an explicit list of post IDs from wc_get_product_ids_on_sale(). The archive template renders, the URL works, the products appear. On a clean install it looks like it works.

Why it is structurally wrong, not just old#

Everything below follows from one decision: the snippet fakes membership at query time. No product is ever actually in the sale category. The database has an empty term and a filter that lies about it once per front-end page load.

Nothing is in the category in wp-admin#

Open Products → Categories and Sale shows a count of 0, forever. Filter the products list by Sale and you get nothing. Neither is a display glitch — it is the truth. The term has no relationships. That means you cannot bulk-edit “everything on sale”, you cannot see at a glance whether a product is in the promotion, and every warehouse export, feed generator or ERP bridge that reads term relationships sees an empty category. The admin products list filters by category, product type and stock status, with no on-sale option, so you have no second way to check either.

The empty-array trap#

This is the one that will actually embarrass you. post__in with an empty array does not return zero posts in WordPress — it returns everything. WP_Query only applies the constraint when the array is non-empty, so an empty array is silently ignored. This is documented behaviour, raised as core ticket #28099 in 2014 and closed as wontfix for backward-compatibility reasons. It will not change.

So the moment nothing in your shop is on sale — the morning after a promotion ends, exactly when you are least likely to be looking — your sale category stops being empty and starts displaying your entire published catalogue at full price under a heading that says Sale. The standard fix is to pass array( 0 ) instead, because no post has ID 0.

The PHP 8 problem#

Two things in that snippet are wrong on modern PHP. wc_get_product_ids_on_sale() is called twice to build one array, running the entire lookup a second time for nothing. And $q->set( 'product_cat', null ) puts a null into a query var that both core and WooCommerce read as a string. Commenters on the original snippet report an uncaught strstr(): Argument #1 ($haystack) must be of type string, null given on PHP 8.2 sites.

Be precise about the language here, because this detail is repeated wrongly all over the place. Passing null to a non-nullable parameter of an internal function has been a deprecation notice since PHP 8.1, and it stays a deprecation notice for the whole 8.x line — it is scheduled to become a TypeError in PHP 9.0. What throws today is a call made from a file compiled with declare( strict_types=1 ), which is why the same snippet produces a harmless notice on one site and a fatal on another. Either way the fix is identical: pass an empty string, which clears the constraint just as effectively. Corrected, and with the empty-array guard:

add_action( 'woocommerce_product_query', 'el_sale_category_query' );

function el_sale_category_query( $q ) {

	if ( 'sale' !== $q->get( 'product_cat' ) ) {
		return;
	}

	$ids = wc_get_product_ids_on_sale();

	$q->set( 'post_type', 'product' );
	$q->set( 'product_cat', '' );
	$q->set( 'post__in', $ids ? $ids : array( 0 ) );
}

That version runs on current PHP and current WooCommerce, and it shows an empty page rather than your whole shop. It is the best the approach can be. It still has every problem below.

The on-sale list is a 30-day transient#

wc_get_product_ids_on_sale() reads the wc_products_onsale transient and, on a miss, queries the product data store and caches the result for 30 days. The lookup is a query against stored sale prices — it does not build product objects and it does not call is_on_sale(). Anything that computes a discount at runtime rather than storing a sale price is therefore invisible to it: dynamic pricing rules, role-based pricing, bulk-quantity discounts, most currency-switcher setups. Those products are on sale to the customer and absent from your sale page.

The transient is cleared by wc_delete_product_transients(), which is hooked to save_post_product, so ordinary edits do refresh it. Changes that bypass a product save — a direct SQL update, some import tools, a plugin writing meta without triggering WooCommerce’s hooks — do not, and the stale list can sit there for a month.

Scheduled sales do not start when you think#

Sale start and end dates are applied by wc_scheduled_sales(), run by a single recurring action, woocommerce_scheduled_sales, registered with a 24-hour interval at whatever time it was first scheduled — not at midnight. There are no per-product events at the exact sale time. If that action was first scheduled at 20:20, a sale set to begin today begins at 20:20 today. The mismatch is an open issue in the WooCommerce tracker. Layer WP-Cron’s own request-triggered nature on top and a low-traffic store can be hours later again.

You can check yours under WooCommerce → Status → Scheduled Actions, searching for woocommerce_scheduled_sales. This one is not the snippet’s fault — it affects every approach on this page, including the good ones — but the snippet has no way to compensate.

Filter plugins fight it#

Faceted filter plugins build their index from term relationships and their counts from the taxonomy query. Your sale page has neither. Depending on the plugin you get zero results for every facet, wrong counts, or a collision when it tries to intersect its own post__in with yours — because both are writing to the same query var, and the last one to run wins. Even WooCommerce’s own attribute filter widgets throw warnings on this page while working normally everywhere else, which is exactly what the comment threads report.

One thing the snippet is usually blamed for unfairly#

It is widely repeated that variable products with a sale price on a single variation slip through. They do not. wc_get_product_ids_on_sale() merges the IDs it finds with their non-zero parent IDs, so a variable product with one discounted variation is in the list. The variation IDs are in the list too, but they are inert here — post_type is product, and variations are product_variation, so they never render. Variable products are the part that works.

Making a WooCommerce sale category automatic: three honest options#

ApproachReal category URLVisible in wp-adminTerm count correctFilters work
On Sale Products collection / shortcodeNon/an/aNo
Plugin that builds a PageNo — it is a Pagen/an/aPartly
Rule-driven real categoryYesYesYesYes

1. Display only: the On Sale Products collection or shortcode#

If all you need is a block of discounted products somewhere on the site, WooCommerce already ships it and you should use it. In the editor, insert the Product Collection block and choose the On Sale Products collection. The older standalone product grid blocks were soft-deprecated in WooCommerce 9.5 — they still run where they already exist, but they are hidden from the inserter, so do not build anything new on them.

In classic content, use the modern products shortcode. [sale_products] is the legacy form, folded into [products] in WooCommerce 3.2:

[products on_sale="true" limit="12" columns="4" orderby="date" order="DESC"]

This is genuinely automatic, needs no code and cannot break. What you do not get: a product category URL, a breadcrumb, a place in your category menu, pagination that behaves like a shop archive, or any interaction with faceted filters. It is a widget, not a category. For a homepage strip that is exactly right. For a page you want to rank and link to in navigation, it is not enough.

2. A plugin that builds a Page#

Several plugins offer “dynamic collections” that are really a WordPress Page with a saved query attached. You get a URL and a template, which is more than option 1. You still do not get a product_cat term, so it is not in the category hierarchy, it cannot be a child of Clothing, WooCommerce’s own category widgets and breadcrumbs ignore it, and your term counts are unaffected. Fine if the page stands alone. Wrong if it needs to sit inside your existing structure.

3. A real category, populated by rules#

The only approach with no asterisks is the boring one: put the products in the category. Actually assign the term, so wp_term_relationships has real rows. Then everything downstream — admin lists, counts, breadcrumbs, menus, filter plugins, feeds, your theme’s category widget — works because nothing is being tricked. The problem becomes purely one of maintenance: who adds and removes products, and when. That is the same question behind assigning products to a category based on rules generally, and it is answerable by hand, by WP-CLI on a cron, or by a plugin that evaluates rules for you.

Doing it by hand is not ridiculous for a small catalogue and a quarterly promotion. If you are already bulk-assigning products to categories each month, adding Sale to that routine costs you nothing new. It stops being reasonable the moment sale prices change weekly or come from an automated repricer.

The 40% off variant nobody answers#

The most common follow-up question on every version of this snippet is tiering: not everything on sale, only things discounted 40% or more. The usual answer is that it needs custom work, which is true but unhelpful, so here is the code.

There is no discount-percentage column to query. You have to instantiate each product and compute it, and for variable products you have to look at each variation, because a product where one size is 50% off and the rest are 10% off is a different thing from a product uniformly 40% off. Decide which one you mean. This version uses the deepest discount available on any variation:

function el_best_discount_percent( $product ) {

	$children = $product->is_type( 'variable' ) ? $product->get_children() : array();
	$targets  = $children ? $children : array( $product->get_id() );
	$best     = 0;

	foreach ( $targets as $target_id ) {

		$item = wc_get_product( $target_id );

		if ( ! $item || ! $item->is_on_sale() ) {
			continue;
		}

		$regular = (float) $item->get_regular_price();
		$active  = (float) $item->get_price();

		if ( $regular <= 0 || $active >= $regular ) {
			continue;
		}

		$best = max( $best, ( ( $regular - $active ) / $regular ) * 100 );
	}

	return $best;
}

add_action( 'woocommerce_product_query', 'el_deep_sale_category' );

function el_deep_sale_category( $q ) {

	if ( 'sale-40' !== $q->get( 'product_cat' ) ) {
		return;
	}

	$ids = get_transient( 'el_sale_40_ids' );

	if ( false === $ids ) {

		$ids = array();

		foreach ( wc_get_product_ids_on_sale() as $id ) {

			$product = wc_get_product( $id );

			if ( ! $product || $product->is_type( 'variation' ) ) {
				continue; // parents are already in the list
			}

			if ( el_best_discount_percent( $product ) >= 40 ) {
				$ids[] = $product->get_id();
			}
		}

		set_transient( 'el_sale_40_ids', $ids, HOUR_IN_SECONDS );
	}

	$q->set( 'post_type', 'product' );
	$q->set( 'product_cat', '' );
	$q->set( 'post__in', $ids ? $ids : array( 0 ) );
}

Note the extra transient. Without it you are loading every on-sale product and its variations on every page load of that category, which on a few hundred discounted products is a visible delay. With it, your tier page is up to an hour stale. Note also that this inherits every structural problem above: still no term, still no admin visibility, still nothing a filter plugin can see. It is a better answer to the wrong question.

If you want it to be a real category#

This is the part where I tell you what we build, so weigh it accordingly. Smart Categories for WooCommerce is a free plugin that attaches a rule set to a category you already have — a genuine product_cat term, not a parallel taxonomy — and assigns that term to the products that match. Because the membership is real, the term count is right, the products show up when you filter the admin list, and filter plugins have something to index.

For this job the rule is On sale is true. For the tier variant it is On sale is true AND Discount percentage (%) is greater than or equal to 40 — discount percentage is one of the 35 match fields in the free version, so the tiering problem above is a two-line rule rather than 40 lines of PHP. Groups nest and any group can be negated, so on sale, 40% or more, in stock, but not in Clearance is one rule set.

The rule builder with nested AND, OR and NOT groups and a live count of matching WooCommerce products
Groups nest to any depth, and the preview counts matching products while you build.

Two details matter specifically for sales. Rules re-run on product create and update and on any price or stock change, and there is a daily sweep in addition — that sweep is what catches a sale that started or ended while nobody was editing anything. Be clear about its limit, though: the sweep reads the sale state WooCommerce has already applied, so it closes the gap between a price changing and the category noticing, but it cannot make WooCommerce apply a scheduled sale any earlier than WooCommerce’s own daily action does. The cron timing described above sits upstream of every approach on this page. The second detail: the plugin records only the memberships it created, so a product you added to Sale by hand is never removed by a rule.

Matching runs through Action Scheduler in batches rather than on page load, and you can see the matching products and their count in a live preview before you save anything. Ordering for the resulting page is handled there too, including pushing out-of-stock products to the end, which matters more on a sale page than anywhere else in the shop.

A smart category just created and populated with matching WooCommerce products
Matching products are assigned as soon as you save — it is a real product category, not a filtered view.

Be equally clear about what it does not cover. It does not write SEO titles, descriptions or canonicals — that stays with your SEO plugin — and it does not create redirects. If you are restructuring categories rather than adding one, read changing your category structure without losing rankings first, because redirects are the part that actually costs you traffic.

The morning the sale ends#

Every approach here is judged by what happens the day the promotion stops, because that is the moment nobody tests.

  • The original snippet. Sale prices are cleared by the daily action, wc_get_product_ids_on_sale() returns an empty array, post__in is ignored, and /product-category/sale/ serves your whole catalogue at full price. Google may crawl it before you notice.
  • The corrected snippet. Empty page, correct behaviour, but the term still says 0 products so nothing in the admin tells you the promotion is over.
  • Block or shortcode. Renders nothing, or WooCommerce’s no-products message inside whatever page it lives on. Safe, but that page now has a hole in it.
  • Real term membership. The rule stops matching, the terms are removed, the count drops to 0, and the category is genuinely empty — which is a state your theme and your SEO plugin already know how to handle, and which you can see from the categories screen.

An empty sale category is still a live URL serving a page with no products, and nothing here will hide or unpublish it for you — not WooCommerce, and not a rules plugin either. Decide in advance whether it should be noindexed between promotions or left alone. The answer usually depends on whether it has accumulated links, and it is the same judgement discussed in why category pages are not indexed. Whichever you choose, it is your SEO plugin that carries it out.

The short version: the snippet is a display trick wearing a category’s clothes. If you only need a display, use the block — it is free, supported and cannot break. If you need a category, make it a category.

Keep reading

Related articles