This guide is for WooCommerce store owners and developers who need code that connects products and categories. That usually means one of four jobs: listing the products in a category, checking whether a product belongs to a category, assigning categories in code or in bulk, or storing a list of products and categories for a rule like a discount or shipping restriction. I build custom WooCommerce plugins for stores in the US, UK, EU and Australia, and these patterns come up in nearly every project. Each section below has code you can adapt, plus the mistakes I see most often.
Last verified: October 2026, against WordPress 6.x developer docs and the WooCommerce trunk source.
Pick the right tool for the job
Before writing code, match the task to the function built for it. Most bugs I fix in this area come from using a general tool where a WooCommerce-specific one exists, or the reverse.
| Task | Best tool | Why |
|---|---|---|
| List products in one or more categories | wc_get_products() with category | Uses the WooCommerce data store and returns WC_Product objects or IDs |
| Exclude a category, or combine AND/NOT IN logic | WP_Query with tax_query | Full control over operators and include_children |
| Check if a product is in a category | has_term() or wc_get_product_cat_ids() | Cheap check against cached term relationships |
| Assign categories in plugin code | $product->set_category_ids() then save() | Runs WooCommerce hooks and cache clearing |
| Assign categories to hundreds of products | CSV importer or WP-CLI | No PHP to deploy, repeatable |
| Related products by category | wc_get_related_products() plus filters | Built-in caching per product |
How WooCommerce stores product categories
Product categories are a normal WordPress taxonomy named product_cat. Tags are product_tag. Products are the product post type, and variations are product_variation.
Two details matter for everything below:
- Variations do not have their own categories. Only the parent product is linked to
product_catterms. If you check a variation ID against a category, you will getfalse. Always resolve variations to their parent first. - Categories are not order data. HPOS (High-Performance Order Storage) moves orders into custom tables, but products and their categories still live in
wp_postsand the term tables. Nothing in this guide changes with HPOS on or off.
Get products by category with wc_get_products
For most “get products by category” tasks, wc_get_products() is the function to use. The official wc_get_products documentation lists category as an array of category slugs and product_category_id as an integer or array of category IDs.
$products = wc_get_products( array(
'status' => 'publish',
'category' => array( 'hoodies', 't-shirts' ),
'visibility' => 'catalog',
'limit' => 12,
'orderby' => 'date',
'order' => 'DESC',
) );
foreach ( $products as $product ) {
echo esc_html( $product->get_name() ) . "\n";
}A few behaviours that the documentation does not spell out, which I confirmed by reading the WooCommerce product data store source:
- Multiple slugs in
categoryare combined with OR. The data store builds onetax_queryclause with the defaultINoperator, so the example above returns products in hoodies or t-shirts. - Child categories are included. That clause does not set
include_children, so it defaults totrue. Asking forclothingalso returns products inclothing > hoodies. - If you pass both
categoryandproduct_category_id, onlycategoryis used. visibility => 'catalog'excludes products set to “Search results only” or “Hidden”. Without it, hidden products come back too, which surprises people building front-end lists.- The default
limitis your site’s “Blog pages show at most” setting (posts_per_page), not unlimited.
Return IDs when you only need IDs
Building full product objects is the expensive part. If you only need IDs for a later step, ask for IDs:
$ids = wc_get_products( array(
'status' => 'publish',
'product_category_id' => array( 42 ),
'return' => 'ids',
'limit' => 200,
) );Page through large catalogs instead of using limit -1
'limit' => -1 is fine on a store with 80 products. On a store with 20,000, it loads every match into memory in one request and can time out. Use paginate and process in batches:
$page = 1;
do {
$result = wc_get_products( array(
'status' => 'publish',
'category' => array( 'clearance' ),
'return' => 'ids',
'limit' => 200,
'page' => $page,
'paginate' => true,
) );
foreach ( $result->products as $product_id ) {
// Do the work for one product here.
}
$page++;
} while ( $page <= $result->max_num_pages );With paginate => true, the return value is an object with products, total and max_num_pages, as described in the docs linked above.
WP_Query with tax_query on product_cat
wc_get_products() has no documented argument for excluding a category or switching child categories off. When you need that control, use WP_Query with a tax_query. The WP_Query taxonomy parameters are taxonomy, field (term_id, name, slug or term_taxonomy_id), terms, include_children (default true) and operator (IN, NOT IN, AND, EXISTS, NOT EXISTS, default IN).
This query gets published hoodies, without products from child categories, excluding anything also in clearance, and excluding products hidden from the catalogue:
$query = new WP_Query( array(
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 24,
'fields' => 'ids',
'no_found_rows' => true,
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => array( 'hoodies' ),
'include_children' => false,
),
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => array( 'clearance' ),
'operator' => 'NOT IN',
),
array(
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => array( 'exclude-from-catalog' ),
'operator' => 'NOT IN',
),
),
) );
$product_ids = $query->posts;Notes on the choices here:
fields => 'ids'returns plain IDs, which is much lighter than full post objects.no_found_rows => trueskips the extra row count. Use it whenever you are not printing pagination links.product_visibilityis the taxonomy WooCommerce uses for catalogue visibility, featured status and out-of-stock flags. A rawWP_Querydoes not apply it for you, which is why hidden products show up in custom loops.NOT INclauses get slower as the term relationships table grows. If a query like this runs on every page view, cache it (see the performance section).
If you would rather stay inside wc_get_products(), WooCommerce documents the woocommerce_product_data_store_cpt_get_products_query filter for adding custom query variables. You receive the generated WP_Query arguments and can append your own tax_query clause there.
Check if a product is in a category
This is the “matching” job most plugins need: a discount for products in certain categories, a shipping rule, a notice on the product page. The core function is has_term(), which accepts term IDs, names or slugs and a post ID.
if ( has_term( array( 'hoodies', 'jackets' ), 'product_cat', $product_id ) ) {
// Product is in hoodies OR jackets.
}Passing an array means “any of these”. For “all of these”, chain the checks:
$in_both = has_term( 'hoodies', 'product_cat', $product_id )
&& has_term( 'sale', 'product_cat', $product_id );Two traps:
has_term()checks only the categories assigned to the product. A product inclothing > hoodiesis not “in”clothingunless you also ticked the parent. WooCommerce’swc_get_product_cat_ids( $product_id )returns the assigned category IDs plus all their ancestors, which is usually what store owners mean.- Pass the parent ID for variations, as covered above.
Match a product against a saved list of products and categories
The earlier version of this article focused on this pattern, and it is still the one I use in most custom plugins. An admin picks some products and some categories in a settings screen, and the plugin needs to know whether a given product matches.
Store the selection
For a site-wide rule, store IDs in a single option. Save them as an array of integers. WordPress serialises arrays in options for you; if you prefer JSON for portability, encode with wp_json_encode() and decode with json_decode( $value, true ), because without true you get an object, not an array.
function ra_save_rule( array $product_ids, array $category_ids ) {
update_option( 'ra_rule_targets', array(
'products' => array_values( array_unique( array_map( 'absint', $product_ids ) ) ),
'categories' => array_values( array_unique( array_map( 'absint', $category_ids ) ) ),
), false );
}The last argument of update_option() turns off autoload, so the option is not loaded on every request when only checkout uses it. For per-product settings, use product meta through the CRUD ($product->update_meta_data()), not a global option.
The matching function
function ra_product_matches_rule( $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return false;
}
$targets = (array) get_option( 'ra_rule_targets', array() );
$product_ids = isset( $targets['products'] ) ? (array) $targets['products'] : array();
$category_ids = isset( $targets['categories'] ) ? (array) $targets['categories'] : array();
// Variations match on their own ID or their parent's.
$parent_id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
if ( in_array( $product->get_id(), $product_ids, true ) || in_array( $parent_id, $product_ids, true ) ) {
return true;
}
if ( empty( $category_ids ) ) {
return false;
}
// Assigned categories plus their ancestors.
$product_cat_ids = array_map( 'intval', wc_get_product_cat_ids( $parent_id ) );
return (bool) array_intersect( $category_ids, $product_cat_ids );
}Strict in_array() only works because both sides are integers. Mixing a saved "42" string with an integer 42 is the most common reason I see a rule silently fail, so sanitise with absint() on save and intval() on read.
Using it in the cart
Cart items carry the parent in product_id and the variation in variation_id. When looping over WC()->cart->get_cart(), pass variation_id when it is set and product_id otherwise, and the function above handles both.
Assign products to categories programmatically
Inside a plugin: use the product CRUD
When you already have a WC_Product, set categories on it and save. This goes through the WooCommerce data store, fires its hooks and clears its caches.
$product = wc_get_product( 123 );
if ( $product ) {
$current = $product->get_category_ids();
$product->set_category_ids( array_unique( array_merge( $current, array( 42, 57 ) ) ) );
$product->save();
}set_category_ids() replaces the list, so merge with the current IDs when you want to add. If you save a product with no categories and the store has a default category set, WooCommerce assigns that default (usually “Uncategorized”) on save.
Lower level: wp_set_object_terms and wp_set_post_terms
For scripts that only touch term relationships, wp_set_object_terms() takes ( $object_id, $terms, $taxonomy, $append = false ). With $append left at false, it replaces every category on the product. Set it to true to add.
// Add category 42 without removing existing ones.
wp_set_object_terms( 123, array( 42 ), 'product_cat', true );
// Wrong: "42" as a string is treated as a slug or name, not an ID.
wp_set_object_terms( 123, array( '42' ), 'product_cat', true );That second line is a real trap. Integers are treated as term IDs; strings are treated as slugs or names, and a string that matches nothing can create a new term called “42”. Cast IDs with (int) or absint().
wp_set_post_terms() is a wrapper with extra validation for hierarchical taxonomies. Either works for product_cat. What neither does is run WooCommerce’s own product save routine, so after a large batch I run the recount and transient tools described below.
Bulk assignment: admin, CSV and WP-CLI
Admin bulk edit
For a few dozen products, go to Products, select them, choose Edit under Bulk actions, and tick categories. In my experience this adds the ticked categories to the selected products rather than replacing their existing ones, which is safe but means you cannot use it to move products out of a category.
CSV import
For hundreds or thousands of products, the built-in importer is the tool I reach for first. The WooCommerce product CSV importer documentation describes the Categories column as a comma-separated list using > for hierarchy, for example Clothing > Hoodies, Sale. Category names that contain a comma need escaping with a backslash.
The workflow I use:
- Export the products with only the ID, SKU and Categories columns.
- Edit the Categories column in a spreadsheet.
- Import with “Update existing products” ticked. Rows are matched by ID or SKU.
- Spot-check a few products and the category counts.
The importer writes the full category list for each row, so a row with only Sale removes any other categories that product had. Keep the existing values in the column when you mean to add.
WP-CLI one-liners
WP-CLI is the fastest option for repeatable changes on staging and production. These use core commands, so they work on any WordPress install:
# IDs of all published products in the "hoodies" category
wp post list --post_type=product --post_status=publish --product_cat=hoodies --format=ids
# Add the "sale" category to every product in "hoodies"
for id in $(wp post list --post_type=product --product_cat=hoodies --format=ids); do
wp post term add "$id" product_cat sale
done
# Remove a category from one product
wp post term remove 123 product_cat clearancewp post term add appends to existing terms and treats values as slugs by default; pass --by=id to use IDs.
The WooCommerce CLI wraps the REST API and needs a user, per the WooCommerce CLI FAQ. Category lists are passed as JSON:
wp wc product update 123 --categories='[{"id":42},{"id":57}]' --user=1This sets the full list for that product, the same as the REST API. After a big CLI or SQL batch, recount and clear caches:
wp wc tool run recount_terms --user=1
wp wc tool run clear_transients --user=1Always run batches on a staging copy first and keep a database backup. A wrong $append value or an incomplete CSV column can strip categories from a whole catalogue in seconds.
Related products by category
WooCommerce already matches related products on shared categories and tags. The signature is wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array(), $related_by = array() ), and results are cached per product in a wc_related_{product_id} transient for a day.
To relate by category only, switch tags off:
add_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_false' );If products sit in broad parent categories such as “All products”, related items will feel random, because every product shares that parent. The fix is usually taxonomy cleanup, not more code: assign the specific child category and leave the generic parent off.
Performance on large catalogues
What I check before shipping category code to a big store:
- Cache derived lists. Wrap expensive queries in
get_transient()/set_transient()orwp_cache_get()/wp_cache_set()with a persistent object cache. - Invalidate on change. Delete your cache in the
set_object_termsaction when$taxonomyisproduct_cat, and onwoocommerce_update_product. A cache without invalidation is a bug report waiting to happen. - Return IDs.
return => 'ids'orfields => 'ids'whenever you do not need full objects. - No queries inside loops. Read options and build lookup arrays once, before the loop.
- Paginate. Avoid
limit => -1andposts_per_page => -1on anything that can grow.
A simple cached lookup with invalidation:
function ra_get_hoodie_ids() {
$ids = get_transient( 'ra_hoodie_ids' );
if ( false === $ids ) {
$ids = wc_get_products( array(
'status' => 'publish',
'category' => array( 'hoodies' ),
'return' => 'ids',
'limit' => 500,
) );
set_transient( 'ra_hoodie_ids', $ids, DAY_IN_SECONDS );
}
return $ids;
}
add_action( 'set_object_terms', function ( $object_id, $terms, $tt_ids, $taxonomy ) {
if ( 'product_cat' === $taxonomy ) {
delete_transient( 'ra_hoodie_ids' );
}
}, 10, 4 );Categories, URLs and shopping feeds
Two places where category structure matters beyond your own code.
Category URLs. WooCommerce’s permalink settings let you include the category in product URLs (the “Shop base with category” option). That looks tidy, but when a product is in several categories only one can appear in the URL, and changing categories later changes the URL. On an established store, changing the product permalink structure needs redirects planned first.
Google Merchant Center. If you sell through Google Shopping in the US, UK, EU or Australia, your categories often feed two attributes. product_type is your own path, such as Home > Women > Dresses > Maxi Dresses, up to 750 characters, and only the first value is used to organise bidding and reporting. google_product_category is optional, uses Google’s taxonomy, and accepts either the numeric ID or the full path, but not both. Google assigns a category automatically if you leave it out. I prefer the numeric ID on multilingual EU stores because it does not depend on the feed language.
Building a product_type value from the deepest assigned category:
function ra_product_type_path( $product_id ) {
$deepest = 0;
$depth = -1;
foreach ( wc_get_product_term_ids( $product_id, 'product_cat' ) as $term_id ) {
$ancestors = get_ancestors( $term_id, 'product_cat' );
if ( count( $ancestors ) > $depth ) {
$depth = count( $ancestors );
$deepest = $term_id;
}
}
if ( ! $deepest ) {
return '';
}
$path = array_reverse( get_ancestors( $deepest, 'product_cat' ) );
$path[] = $deepest;
$names = array();
foreach ( $path as $id ) {
$term = get_term( $id, 'product_cat' );
if ( $term && ! is_wp_error( $term ) ) {
$names[] = $term->name;
}
}
return implode( ' > ', $names );
}Most feed plugins have a setting for this already, so only write your own when you control the feed.
Frequently asked questions
How do I get all products in a category in WooCommerce?
Use wc_get_products() with 'category' => array( 'your-slug' ) and 'status' => 'publish'. Add 'visibility' => 'catalog' if the list is shown to customers. Set a limit, or use paginate for large categories, instead of -1.
Does wc_get_products include products from subcategories?
Yes. The category clause WooCommerce builds leaves include_children at its default of true, so a parent slug also returns products in its child categories. To exclude them, use WP_Query with 'include_children' => false, or add your own clause through the woocommerce_product_data_store_cpt_get_products_query filter.
How do I exclude a category from a product query?
Use WP_Query with a tax_query clause on product_cat and 'operator' => 'NOT IN'. Combine it with your include clause using 'relation' => 'AND'. wc_get_products() has no documented exclude argument.
Why does has_term() return false for a product I know is in the category?
The usual causes are checking a variation ID instead of the parent, checking a parent category when only the child is assigned, or passing the wrong taxonomy (category instead of product_cat). Use wc_get_product_cat_ids() if parent categories should count as a match.
What is the difference between wp_set_object_terms and set_category_ids?
wp_set_object_terms() writes term relationships directly and replaces existing terms unless $append is true. $product->set_category_ids() followed by save() goes through WooCommerce’s data store, which runs product hooks and clears WooCommerce caches. In plugin code I use the CRUD method; in quick one-off scripts the lower-level function is fine.
Can I change product categories in bulk without a plugin?
Yes. The admin bulk editor can add categories to selected products, the built-in CSV importer can rewrite categories for thousands of products matched by ID or SKU, and WP-CLI’s wp post term add and wp post term remove handle scripted changes. Test on staging first.
Is this code affected by HPOS?
No. HPOS changes where orders are stored. Products, product categories and their relationships still use the posts and terms tables, so these queries and functions behave the same either way.
Why do my category product counts look wrong after a bulk change?
Direct term writes and SQL changes can leave WooCommerce’s cached counts out of date. Run wp wc tool run recount_terms --user=1, or use “Term counts” under WooCommerce, Status, Tools. Clearing WooCommerce transients also helps with stale related-product lists.
If your store needs category-based pricing, shipping rules, custom feeds or a clean bulk recategorisation, I can build and test it on a staging copy before anything touches production. See my WooCommerce plugin development service for how I work and what a project like this involves.

