
WooCommerce stores product data using WordPress’s standard post system — products are the product post type, prices and stock levels live in post meta, and categories/tags are custom taxonomies. That means WP_Query can query products with full precision, and understanding the data structure unlocks filters you can’t get from WooCommerce’s own functions.
This guide covers everything from a basic product query to filtering by price range, stock status, attributes, sale status, and featured flag — with performance notes throughout.
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'paged' => get_query_var( 'paged' ) ?: 1,
] );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$product = wc_get_product( get_the_ID() );
// use $product methods here
}
wp_reset_postdata();
}PHPInside the loop, wc_get_product() gives you the full WC_Product object with all its methods. You can then call $product->get_price(), $product->get_stock_quantity(), $product->get_image_id(), and so on — rather than reading raw meta directly.
WooCommerce uses the product_cat taxonomy for categories and product_tag for tags:
// Single category by slug
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'hoodies',
],
],
] );
// Multiple categories (products in ANY of these)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'hoodies', 'jackets', 'coats' ],
'operator' => 'IN',
],
],
] );
// Products in BOTH categories (AND logic)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'mens',
],
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'sale',
],
],
] );PHPRegular price and sale price are stored in post meta as _regular_price and _sale_price. The current active price is _price. Always filter on _price — it reflects whatever WooCommerce considers the current applicable price including sale rules:
// Products between $20 and $100
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'meta_query' => [
[
'key' => '_price',
'value' => [ 20, 100 ],
'compare' => 'BETWEEN',
'type' => 'NUMERIC', // critical — without this, '9' > '100' as a string
],
],
'orderby' => 'meta_value_num',
'meta_key' => '_price',
'order' => 'ASC',
] );
// Products under $50
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'meta_query' => [
[
'key' => '_price',
'value' => 50,
'compare' => '<=',
'type' => 'NUMERIC',
],
],
] );PHPImportant: Always set 'type' => 'NUMERIC' on price queries. Without it, MySQL compares values as strings. “9.99” would be treated as greater than “100.00” because “9” > “1” in string comparison — a silent bug that returns completely wrong results.
Stock status is stored in the _stock_status meta key with values instock, outofstock, or onbackorder:
// In-stock products only
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'meta_query' => [
[
'key' => '_stock_status',
'value' => 'instock',
],
],
] );
// Out of stock products (useful for admin reporting)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'meta_query' => [
[
'key' => '_stock_status',
'value' => 'outofstock',
],
],
] );PHPWooCommerce doesn’t store a simple “is on sale” flag. A product is on sale when its _sale_price is set and non-empty, and the current date falls within any scheduled sale dates (or no dates are set). The most reliable approach uses WooCommerce’s own helper to get the IDs first:
// Method 1: Use WooCommerce's built-in sale IDs (recommended)
$on_sale_ids = wc_get_product_ids_on_sale(); // returns cached array of IDs
if ( ! empty( $on_sale_ids ) ) {
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'post__in' => $on_sale_ids,
'orderby' => 'post__in', // preserve the order
] );
}
// Method 2: Meta query (less reliable for scheduled sales)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => '_sale_price',
'value' => '',
'compare' => '!=',
],
[
'key' => '_sale_price',
'compare' => 'EXISTS',
],
],
] );PHPMethod 1 is preferred because WooCommerce caches the sale IDs and handles scheduled sale dates correctly. Method 2 may include products where a sale price exists in the database but the sale period has ended.
In WooCommerce 3.0+, “featured” is stored as a product taxonomy term in product_visibility:
// WooCommerce 3.0+ — featured uses product_visibility taxonomy
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 8,
'tax_query' => [
[
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => 'featured',
],
],
] );PHPPre-WooCommerce 3.0 used a _featured meta key with value 'yes'. If you’re on a legacy store, replace the tax_query with 'meta_key' => '_featured', 'meta_value' => 'yes' — but upgrade if at all possible, as that version is years out of support.
Product attributes (like Color, Size) are stored as custom taxonomies prefixed with pa_:
// Products with Color = Red
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
[
'taxonomy' => 'pa_color', // pa_ prefix + attribute slug
'field' => 'slug',
'terms' => 'red',
],
],
] );
// Multiple attributes — Red OR Blue, AND Size = Large
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'pa_color',
'field' => 'slug',
'terms' => [ 'red', 'blue' ],
'operator' => 'IN',
],
[
'taxonomy' => 'pa_size',
'field' => 'slug',
'terms' => 'large',
],
],
] );PHPHere’s a real-world query combining category, price range, stock status, and ordering — the kind you’d build for a filtered shop page:
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 24,
'paged' => get_query_var( 'paged' ) ?: 1,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'shirts',
],
],
'meta_query' => [
'relation' => 'AND',
'price_clause' => [
'key' => '_price',
'value' => [ 10, 80 ],
'compare' => 'BETWEEN',
'type' => 'NUMERIC',
],
[
'key' => '_stock_status',
'value' => 'instock',
],
],
'orderby' => 'price_clause', // named meta clause for orderby
'order' => 'ASC',
] );
// Always check found_posts for pagination
$total_pages = $query->max_num_pages;PHPWooCommerce ships its own WC_Product_Query class that wraps WP_Query with product-specific arguments. It’s worth knowing about for simpler use cases:
$products = wc_get_products( [
'status' => 'publish',
'limit' => 12,
'category' => [ 'shirts' ], // category slugs — no tax_query needed
'stock_status' => 'instock',
'orderby' => 'price',
'order' => 'ASC',
] );
// Returns array of WC_Product objects directly — no loop needed
foreach ( $products as $product ) {
echo $product->get_name() . ': ' . $product->get_price();
}PHPUse wc_get_products() when you want product objects returned directly without a template loop. Use WP_Query directly when you need the_post(), pagination via max_num_pages, or complex combined meta_query / tax_query logic that WC_Product_Query doesn’t expose.
tax_query over meta_query for categorisation. Filtering by product_cat or pa_color hits indexed taxonomy tables. Filtering by _price hits wp_postmeta, which has limited indexing._price if you run price-range queries on a large catalogue. This is a single ALTER TABLE statement on wp_postmeta and can reduce query time dramatically.wc_get_product_ids_on_sale() is already cached as a transient, but if you call it in a custom query, wrap the whole query in a transient with a 12-hour expiry.fields => 'ids' when you only need to count products or pass IDs to another function — avoids loading full post objects.no_found_rows => true on non-paginated product blocks and widgets.Use a meta_query on the _price key with 'compare' => 'BETWEEN' and 'type' => 'NUMERIC'. Always use _price rather than _regular_price — it reflects the current active price including any active sales.
Key meta fields: _price (current price), _regular_price, _sale_price, _stock_status (instock/outofstock/onbackorder), _stock (quantity number), _sku, _weight, _virtual (yes/no), _downloadable (yes/no), _manage_stock (yes/no).
Attributes created via WooCommerce → Attributes are stored as custom taxonomies with a pa_ prefix. The slug “Color” becomes the taxonomy pa_color. Use this in a tax_query just like any other taxonomy.
Common causes: wrong post type (use 'product', not 'products'), missing 'post_status' => 'publish', a meta_query type mismatch (numeric vs string), or a taxonomy slug that doesn’t exist. Add var_dump( $query->request ) to see the raw SQL, or use the Query Monitor plugin to inspect queries on the page.
Use wc_get_products() when you want a flat array of WC_Product objects for data processing. Use WP_Query when you need the full template loop, pagination, or a complex combination of meta_query and tax_query that wc_get_products() doesn’t support. Both ultimately hit the same database tables.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More