WooCommerce Product Query with WP_Query: Filters, Meta, Taxonomy & Performance

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.

Basic WooCommerce Product Query

$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();
}
PHP

Inside 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.

Filter by Product Category

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',
        ],
    ],
] );
PHP

Filter by Price Range

Regular 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',
        ],
    ],
] );
PHP

Important: 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.

Filter by Stock Status

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',
        ],
    ],
] );
PHP

Filter On-Sale Products

WooCommerce 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',
        ],
    ],
] );
PHP

Method 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.

Filter Featured Products

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',
        ],
    ],
] );
PHP

Pre-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.

Filter by Product Attribute

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',
        ],
    ],
] );
PHP

Combining Multiple Filters

Here’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;
PHP

Using WC_Product_Query Instead

WooCommerce 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();
}
PHP

Use 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.

Performance Tips for WooCommerce Queries

  • Prefer 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.
  • Add a database index on _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.
  • Cache sale IDs. 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.
  • Use fields => 'ids' when you only need to count products or pass IDs to another function — avoids loading full post objects.
  • Set no_found_rows => true on non-paginated product blocks and widgets.

Frequently Asked Questions

How do I query WooCommerce products by price range?

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.

What meta keys does WooCommerce use?

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).

How do I get WooCommerce product attributes in a query?

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.

Why does my WooCommerce WP_Query return no results?

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.

Should I use WP_Query or wc_get_products() for WooCommerce?

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.

About Me

Gemini_Generated_Image_6ed8rn6ed8rn6ed8

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

Learn More