Previous Article
Custom Checkbox and Radio Styling in Pure CSS (No Library Needed)
29 November 2018
Two related tasks come up constantly on WooCommerce stores fed by an import: filtering which products appear in shop archives, and hiding duplicates as they are imported. Both are straightforward — but the technique in most older guides stopped working in 2017.
Guides written before WooCommerce 3.0 — including the earlier version of this article — set visibility like this:
// Does nothing on any supported WooCommerce version update_post_meta( $id, '_visibility', 'hidden' );
WooCommerce 3.0 moved product visibility from postmeta to a taxonomy called product_visibility. The meta key is simply ignored now, so that line writes a value nothing ever reads. Products stay visible and nobody can see why.
The taxonomy uses these terms:
| Term | Effect |
|---|---|
exclude-from-catalog | Hidden from shop and category archives |
exclude-from-search | Hidden from site search |
featured | Marked as a featured product |
outofstock | Used by the "hide out of stock" setting |
"Hidden" in the admin means both exclusion terms are set.
The cleanest route is the CRUD API, which handles the taxonomy for you:
function bi_set_product_visibility( int $product_id, string $visibility ): void {
$product = wc_get_product( $product_id );
if ( ! $product instanceof WC_Product ) {
return;
}
// 'visible', 'catalog', 'search' or 'hidden'
$product->set_catalog_visibility( $visibility );
$product->save();
}
Or set the terms directly, which is faster in a bulk import because it skips loading the whole product object:
// Hide completely wp_set_object_terms( $product_id, [ 'exclude-from-catalog', 'exclude-from-search' ], 'product_visibility', false ); // Make visible again — an empty array clears the terms wp_set_object_terms( $product_id, [], 'product_visibility', false );
The fourth argument is $append. Passing true adds terms without removing existing ones, which is almost never what you want here — leave it false so the state is replaced rather than accumulated.
WP All Import fires pmxi_saved_post after each row is created or updated. The original code queried for other products sharing a key, then hid the current one if any existed. The idea is sound; the implementation has two problems.
// Rewritten — correct visibility API, bounded query, no self-match
add_action( 'pmxi_saved_post', 'bi_hide_duplicate_products', 10, 1 );
function bi_hide_duplicate_products( int $post_id ): void {
if ( 'product' !== get_post_type( $post_id ) ) {
return;
}
$link_key = get_post_meta( $post_id, 'woo_linked', true );
if ( '' === $link_key ) {
return;
}
$existing = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'fields' => 'ids',
'posts_per_page' => 1, // we only need to know IF one exists
'no_found_rows' => true, // skip the pagination COUNT
'post__not_in' => [ $post_id ], // never match the row being imported
'meta_query' => [
[
'key' => 'woo_linked',
'value' => $link_key,
],
],
] );
bi_set_product_visibility(
$post_id,
empty( $existing->posts ) ? 'visible' : 'hidden'
);
}
Three changes worth noting.
post__not_in excludes the product currently being imported. Without it, re-importing an existing product finds itself and hides it — so a second import run makes your whole catalogue disappear.
posts_per_page => 1 and no_found_rows matter at import scale. The original fetched every matching product and counted the pagination totals. On a 50,000-row import that is 50,000 unbounded queries; stopping at the first hit turns each into a trivial lookup.
The type guard — pmxi_saved_post fires for every import, including ones creating posts or custom types. Without checking, you would try to set catalog visibility on a blog post.
Here is the other bug in the original:
// Never fires on a WooCommerce archive
if ( $query->is_main_query() && is_category() ) { }
is_category() is true for post category archives. A WooCommerce product category is a different taxonomy, so this condition is false on exactly the pages it was meant to target. The correct tags are is_shop(), is_product_category() and is_product_tag().
add_action( 'pre_get_posts', 'bi_filter_product_query' );
function bi_filter_product_query( WP_Query $query ): void {
// Never touch admin queries, and only ever the main one.
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( ! is_shop() && ! is_product_category() && ! is_product_tag() ) {
return;
}
$meta_query = (array) $query->get( 'meta_query' );
$meta_query[] = [
'key' => '_bi_approved',
'value' => 'yes',
'compare' => '=',
];
$query->set( 'meta_query', $meta_query );
}
The is_admin() check is not optional. pre_get_posts runs for every query on the site, including the products list in wp-admin — filter it there and shop managers silently lose products from their own screens.
Reading the existing meta_query and appending, rather than overwriting with set(), preserves whatever WooCommerce and other plugins have already added. Overwriting it is a common cause of stock filtering and price sorting quietly breaking.
For product archives specifically, WooCommerce provides a dedicated hook that runs after its own query is built:
add_filter( 'woocommerce_product_query_meta_query', 'bi_extra_meta_query', 10, 2 );
function bi_extra_meta_query( array $meta_query, WC_Query $wc_query ): array {
$meta_query[] = [
'key' => '_bi_approved',
'value' => 'yes',
'compare' => '=',
];
return $meta_query;
}
Prefer this where it applies — it is scoped to product queries, so there is no risk of leaking into unrelated ones.
This is the performance point worth internalising. meta_query joins wp_postmeta, a table with no useful index on meta_value — it is LONGTEXT, which cannot be indexed efficiently. On a large catalogue, filtering by meta means a scan.
Taxonomy queries use wp_term_relationships, which is properly indexed:
// Slower on a large catalogue
'meta_query' => [ [ 'key' => '_bi_approved', 'value' => 'yes' ] ]
// Faster — indexed
'tax_query' => [ [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => 'exclude-from-catalog',
'operator' => 'NOT IN',
] ]
If you are adding a flag purely to filter archives, register a hidden taxonomy for it rather than using postmeta. It is barely more code and it scales.
When a query is not behaving, inspect the SQL WordPress actually generated:
add_action( 'pre_get_posts', function ( $query ) {
if ( ! is_admin() && $query->is_main_query() && is_shop() ) {
add_filter( 'posts_request', function ( $sql ) {
error_log( $sql );
return $sql;
} );
}
}, 999 );
Remove it before deploying. Query Monitor does the same job with a proper interface and is worth installing on any store you maintain.
_visibility postmeta was replaced by the product_visibility taxonomy in WooCommerce 3.0.$product->set_catalog_visibility(), or set the terms directly for bulk work.is_shop() / is_product_category() — is_category() is for blog posts.pre_get_posts with is_admin() and is_main_query().meta_query rather than replacing it.post__not_in in import hooks.posts_per_page and no_found_rows.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.