Skip to content
Pricing and Quotes

WooCommerce Hide Price Range on Variable Products: All Three Surfaces

WooCommerce hide price range: variable products print the number three times - parent HTML, variation JSON, Store API. Filter all three, then verify.

You added a filter to the price HTML, reloaded a variable product, and the range disappeared. Where the page used to read $89.00 – $340.00 it now reads Price on request. Then someone opened view-source and found all eleven variation prices sitting in the markup as JSON, and the block-theme shop page still drew a price slider running from 89 to 340.

Most WooCommerce hide price range advice stops at that first filter, and on a simple product that is genuinely all it takes. Variable products are the hard case, because the same number is produced three separate times by three separate code paths for three separate consumers. This article walks each one, gives the exact hook, and shows how to confirm the result with view-source and a single unauthenticated REST call instead of trusting what the page looks like.

The three places a variable product’s price is rendered#

A simple product has one price and one renderer. A variable product has a parent that summarises its children, a form that ships every child to the browser so the price can change without a page load, and a REST representation that block themes read instead of the PHP output. Each of those is written by different code, and hiding one does nothing to the other two.

1. The parent price_html range#

WC_Product_Variable::get_price_html() asks the data store for the minimum and maximum price across the visible children, formats them with wc_format_price_range(), and passes the string through woocommerce_variable_price_html — or woocommerce_variable_sale_price_html when both ends are on sale — before the generic woocommerce_get_price_html runs. This is what the shop loop, the single product summary, related products, cross-sells and most widgets print. It is server-rendered HTML, and it is the surface every snippet on the internet targets.

2. The inline variation JSON in the add-to-cart form#

When the variations form renders, WC_Product_Variable::get_available_variations() builds an array for every visible variation and the template prints it into the form’s data-product_variations attribute. Each entry contains display_price, display_regular_price and a fully rendered price_html string, so add-to-cart-variation.js can swap the displayed price the instant a shopper picks an attribute, with no round trip to the server. Filtering the parent range never touches this array — it is produced by a different method entirely, and it is plain text in the page source.

There is a wrinkle worth knowing. Above woocommerce_ajax_variation_threshold (30 variations by default) the template prints false instead of the array, and the browser fetches variations one at a time through the get_variation AJAX endpoint. That endpoint builds each entry through the same get_available_variation() call, so one filter covers both paths — but only if you hook the data, not the template.

3. The Store API price_range#

Block themes and the WooCommerce product blocks do not read the PHP-rendered price at all. They call /wp-json/wc/store/v1/products/<id>, which returns a prices object; for a variable product that object carries a price_range with min_amount and max_amount in minor units. The Filter by Price block goes further and calls /wp-json/wc/store/v1/products/collection-data?calculate_price_range=true, which returns the minimum and maximum across the whole result set so the slider knows where to put its handles.

The Store API is unauthenticated by design — the front end has to reach it before anyone logs in. That makes it different in kind from the wc/v3 REST API, which needs consumer keys. Anyone with a browser can request it, so a shop that hides prices visually but answers this route honestly has not hidden anything. It is also the surface we test hardest, and the reason our leak-testing process for WooCommerce plugins starts with a REST client rather than a screenshot.

Why most WooCommerce hide price range snippets stop too early#

The usual recipe is a single filter on woocommerce_get_price_html. It is not wrong; it is incomplete. Run it on a variable product and this is the honest result: the visible range is gone, the variation dropdowns still populate normally, and the moment a shopper selects Blue / Large the JavaScript writes $149.00 into the price element — because it read that string out of the JSON your filter never saw. On a block theme the shop page may not even change, because the product block renders from the Store API response and ignores your PHP filter completely.

This is the general shape of the problem with hiding data in a template: templates are the last step, and every modern WooCommerce front end has ways to skip them. The same reasoning applies to hiding prices until a customer logs in — the gate has to sit on the data, not on the markup.

Filtering all three surfaces#

Put these in a small site plugin rather than functions.php, so a theme switch cannot silently expose your catalogue. Start with the parent range. Priority 100 keeps you late enough to override most themes.

add_filter( 'woocommerce_variable_price_html', 'shop_hide_variable_range', 100 );
add_filter( 'woocommerce_variable_sale_price_html', 'shop_hide_variable_range', 100 );
add_filter( 'woocommerce_get_price_html', 'shop_hide_variable_range', 100 );

function shop_hide_variable_range( $price_html ) {
	return '<span class="price-on-request">'
		. esc_html__( 'Price on request', 'my-shop' )
		. '</span>';
}

Next the variation payload. One filter handles both the inline JSON and the AJAX endpoint, because both build their entries through get_available_variation().

add_filter( 'woocommerce_available_variation', 'shop_hide_variation_prices', 100, 3 );

function shop_hide_variation_prices( $data, $product, $variation ) {
	$data['price_html'] = '<span class="price-on-request">'
		. esc_html__( 'Price on request', 'my-shop' )
		. '</span>';
	$data['display_price']         = '';
	$data['display_regular_price'] = '';

	return $data;
}

Emptying display_price is the part to test in your own theme. Core’s variation script does not calculate from it, but bundle, deposit and currency-switcher plugins sometimes do, and a few will throw a JavaScript error on an empty string. If something breaks, set both keys to 0 instead and check that add-to-cart still works.

Finally the Store API. There is no single price filter there, so the reliable approach is to rewrite the response after the route handler has run. This walks the whole payload, which means one function covers single products, collections, the cart and collection-data.

add_filter( 'rest_request_after_callbacks', 'shop_blank_store_api_prices', 100, 3 );

function shop_blank_store_api_prices( $response, $handler, $request ) {
	if ( ! $response instanceof WP_REST_Response ) {
		return $response;
	}
	if ( 0 !== strpos( $request->get_route(), '/wc/store/' ) ) {
		return $response;
	}

	$data = $response->get_data();
	shop_blank_prices_walk( $data );
	$response->set_data( $data );

	return $response;
}

function shop_blank_prices_walk( &$node ) {
	if ( ! is_array( $node ) ) {
		return;
	}

	if ( isset( $node['prices'] ) && is_array( $node['prices'] ) ) {
		foreach ( array( 'price', 'regular_price', 'sale_price' ) as $key ) {
			if ( isset( $node['prices'][ $key ] ) ) {
				$node['prices'][ $key ] = '';
			}
		}
		// The variable-product range lives here.
		if ( isset( $node['prices']['price_range'] ) ) {
			$node['prices']['price_range'] = null;
		}
	}

	if ( isset( $node['price_html'] ) ) {
		$node['price_html'] = esc_html__( 'Price on request', 'my-shop' );
	}

	// /products/collection-data?calculate_price_range=true
	if ( isset( $node['price_range']['min_price'] ) ) {
		$node['price_range'] = null;
	}

	foreach ( $node as &$child ) {
		shop_blank_prices_walk( $child );
	}
	unset( $child );
}

Do not zero the variation prices themselves#

The tempting shortcut is to hook woocommerce_variation_prices_price and return zero, on the theory that a range of 0–0 is no range at all. Two things go wrong. First, that hook feeds the price array WooCommerce caches in a transient keyed by a hash, and if you change the values without also varying woocommerce_get_variation_prices_hash, the poisoned array is served to every visitor — including the logged-in wholesale customer who is supposed to see numbers. Deleting the transient does not help; it is regenerated under the same key. Second, min and max are not display-only values: get_price_html(), the variations form and anything else that asks a variable product for its price range read that same array, so you have not hidden a number, you have replaced it with a wrong one.

Hooking the getter instead — woocommerce_product_variation_get_price, or woocommerce_product_get_price — is worse. That is the value the cart, the order total and the payment gateway compute from, so a shopper who reaches checkout pays zero. Hide the presentation, keep the arithmetic. If a customer is not allowed to see the price, the sensible next step is usually to stop them checking out at all, which is catalog mode rather than a price filter.

The fourth surface, if you output structured data#

WooCommerce emits JSON-LD for products, and for a variable product with a spread between its cheapest and dearest child that becomes an AggregateOffer with lowPrice and highPrice — your range again, in a script tag, in plain text. Strip it with woocommerce_structured_data_product.

add_filter( 'woocommerce_structured_data_product', 'shop_strip_offers', 100 );

function shop_strip_offers( $markup ) {
	unset( $markup['offers'] );
	return $markup;
}

That covers core only. Rank Math, Yoast, All in One SEO and the standalone schema plugins each build their own product graph with their own hooks, so if one of those is active, search the rendered page for the number before assuming you are done.

How to check: view-source and one REST call#

Looking at the page proves nothing, because the interesting data is in an attribute and in a JSON response. Treat any WooCommerce hide price range work as unfinished until these three commands come back empty. Run them logged out, and against a real variable product with several variations.

# 1. Is the variation payload still in the HTML?
curl -s 'https://example.com/product/steel-shelving/' \
  | grep -o 'data-product_variations="[^"]*"' | head -c 400

# 2. What does the Store API hand an anonymous visitor?
curl -s 'https://example.com/wp-json/wc/store/v1/products/1234' \
  | python -m json.tool | grep -A 8 '"prices"'

# 3. What range does the Filter by Price block receive?
curl -s 'https://example.com/wp-json/wc/store/v1/products/collection-data?calculate_price_range=true'

# In the browser console, on the product page:
# JSON.parse( document.querySelector('form.variations_form').dataset.product_variations )

Command one is the one that catches most people. If it prints escaped JSON containing display_price with real numbers, every variation price on that product is public no matter what the page looks like. Command three is the one that catches block themes: a slider that still spans 89 to 340 has told the visitor your range even though no product tile shows a price.

Doing this by hand for one product is fine; doing it for four hundred after every theme update is not. That is why PriceVeil ships the checks as WP-CLI commands — wp priceveil selftest walks every product and asserts that the price HTML, the structured data, the Store API product response and the collection price range are all blank, exiting non-zero on any leak, and wp priceveil scan reports exposure per surface as JSON you can diff in CI.

Surface by surface#

SurfaceWho reads itHookIf you skip it
Parent range HTMLShop loop, product page, widgetswoocommerce_variable_price_htmlThe range prints as text
Inline variation JSONadd-to-cart-variation.jswoocommerce_available_variationEvery variation price is in view-source
get_variation AJAXSame script, above 30 variationswoocommerce_available_variationPrices arrive one request at a time
Store API product pricesBlock themes, product blocks, headlessrest_request_after_callbacksprice_range is public and unauthenticated
Collection price rangeFilter by Price blockrest_request_after_callbacksThe slider still shows min and max
JSON-LD offersSearch engines, SEO pluginswoocommerce_structured_data_productlowPrice and highPrice in the source

What this does not solve#

Being straight about the edges matters more than the snippet count.

  • Sorting still leaks order. The wc_product_meta_lookup table keeps min_price and max_price for every product, and price sorting reads that table rather than your filters. Sort by price and the catalogue arranges itself cheapest-first, which tells a competitor the relative shape of your pricing even with no numbers on screen. Remove the price sorting options if that matters.
  • Theme and custom code that calls the getter directly. A template calling $product->get_price() or get_variation_prices() and echoing the result bypasses every display filter above. Nothing can fix that from outside the theme.
  • Product feeds. CTX Feed, Google Product Feed and merchant integrations read the database, not your filters, and will happily publish the full range. Turn them off deliberately.
  • Pages cached before the change. Full-page caches and CDN edges keep serving the old HTML until purged once.
  • Buying. Hiding the number does not remove the Add to Cart button — a shopper can still add a variation and see the price in the cart. Handle that separately, either by removing the add-to-cart button or by replacing it with a request-a-quote flow.

What to do next#

Add the filters above to a site plugin, then run the three checks against one variable product with many variations and one with two. If all three come back blank, you have covered the surfaces WooCommerce itself produces. After that, decide the policy question the code cannot answer for you: hidden from everyone, or hidden from guests only, and what a visitor is supposed to do instead of seeing a number.

If you would rather not maintain the hook list through Woo releases — the Store API in particular gains fields — the same coverage, the audience switch and the CLI checks are described in the PriceVeil documentation, and its settings screen prints a coverage report naming both what is handled automatically and what you still have to check yourself. Either way, re-run the view-source check after your next theme update. That is where these regressions come from.

Keep reading

Related articles