Changing Which Products the Shop Shows
The hook changes the main product query on the shop and archive pages, and nothing else: not shortcodes, not product blocks, not the REST API. Each of those has its own filter, and the usual complaint is a rule that works on the shop page and nowhere else.
The hook changes the main product query — the one rendering the shop, categories, tags and product search — and that is the whole of its reach. woocommerce_product_query fires from inside WooCommerce's pre_get_posts handler, after it has added the clauses it needs, and hands you the WP_Query object, with the WC_Query instance as a second argument. It is the right place to exclude a category from the shop or restrict a wholesale-only range to logged-in buyers. It is not a place that product blocks, shortcodes or the REST API ever visit.
The working shape
add_action( 'woocommerce_product_query', 'ti_hide_wholesale_from_guests' );
function ti_hide_wholesale_from_guests( WP_Query $q ) {
if ( is_user_logged_in() ) {
return;
}
$tax_query = (array) $q->get( 'tax_query' );
$tax_query[] = array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => array( 'wholesale' ),
'operator' => 'NOT IN',
);
$q->set( 'tax_query', $tax_query );
}
The three lines around tax_query are the whole lesson. WooCommerce has already placed its visibility clauses there — products excluded from the catalogue, and out-of-stock products when the store hides them — plus any active layered-navigation and rating filters. A callback that does $q->set( 'tax_query', array( ... ) ) with a fresh array drops all of that: hidden and out-of-stock products reappear and attribute filters stop narrowing. The current category survives, because it arrives through the product_cat query variable rather than the tax query, which is why the damage is easy to miss on a category page. Read, append, set.
The same applies to meta_query. For stock, price and rating WooCommerce often uses lookup tables and posts_clauses rather than meta, so a meta query against _price works but is slower than the ordering WooCommerce already provides, which is controlled by woocommerce_get_catalog_ordering_args instead.
What the hook does not cover
| Listing | Filter to use |
|---|---|
| Shop, category, tag, product search | woocommerce_product_query |
[products] and other shortcodes |
woocommerce_shortcode_products_query |
| Product Collection block | query_loop_block_query_vars (check $block->context['query']['isProductCollectionBlock'] or the block name) |
| Server-rendered grid blocks (Best Selling, On Sale, Newest, Hand-picked) | Their own WP_Query with no dedicated filter; the All Products block queries through the Store API |
| Related, upsells, cross-sells | woocommerce_related_products, woocommerce_product_get_upsell_ids, woocommerce_product_get_cross_sell_ids |
| REST API | woocommerce_rest_product_object_query |
| Admin product list | pre_get_posts with an is_admin() check; not touched by any of the above |
A rule that genuinely has to hold everywhere is better expressed as product visibility — the exclude-from-catalog and exclude-from-search terms of product_visibility, set per product — because every listing respects visibility and only the main query respects this hook. Where the rule is dynamic (per role, per time), each listing type the store uses needs its own callback. If some of your products are missing from one listing and present in another, that mismatch is the diagnostic, and products not showing walks through it from the storefront side.
Ordering and paging
WooCommerce computes the ordering — from the dropdown or the default sort — and sets orderby on the query before this hook fires, so setting orderby here overrides the shopper's choice every time: the dropdown appears to work and does nothing. Change the default ordering through woocommerce_get_catalog_ordering_args instead and leave orderby alone in the query hook. Note too that for price, popularity and rating sorts WooCommerce has already attached posts_clauses callbacks by the time the hook runs, so an override here can be only partly applied.
posts_per_page is also set just before the hook, from the loop_shop_per_page filter, which is the canonical place to change it. The shop's product-per-page count is also a Customizer setting under WooCommerce › Product Catalog, and a setting is easier to find a year later than a snippet. The same goes for the catalogue's default sort, which lives in the same screen and is where the product archive customiser expects to find it.
The search case
Product search runs through the main query with s set, and WooCommerce routes it through this hook as well. A tax query appended here therefore also narrows search results, which is normally right. The one trap is a site-wide search that includes posts: WooCommerce only takes over the query when it is a product query, so a mixed search is a different code path and your exclusion will not apply to it.
Verify
Test five URLs, not one: the shop page, a category that contains an excluded product, a category that does not, a product search, and a page using a product block. The first four should reflect the rule; the fifth will not until its own filter exists. Then log in as a user who should see the full range and repeat. If hidden or out-of-stock products appear, or attribute filters stop narrowing the list, you replaced tax_query instead of appending and dropped WooCommerce's own clauses. Caching plugins hold archive pages, so clear them between tests, otherwise the symptom described in filter not working appears for an unrelated reason.
Frequently asked
- Product blocks never run woocommerce_product_query. The Product Collection block can be filtered with query_loop_block_query_vars; the server-rendered grid blocks run their own WP_Query with no dedicated filter; the All Products block queries through the Store API. Shortcodes have woocommerce_shortcode_products_query. One rule, several filters.
- It did not — the category comes from the product_cat query variable, not the tax query. What $q->set('tax_query', ...) with a fresh array does drop is WooCommerce's visibility clause and any layered-navigation filter, so hidden products reappear and attribute filters stop working. Read the existing array, append your clause, and set the merged array.
- woocommerce_product_query runs inside WooCommerce's own pre_get_posts callback, after WooCommerce has set up its clauses, and only for the main product query. Using it saves you the is_main_query and post_type checks and runs late enough that WooCommerce does not overwrite you.