WooCommerce ships product schema by default. It also ships an incomplete version of it — the core plugin’s JSON-LD output covers the basics that validate but not the properties that actually move AI surfacing. The fix isn’t a new plugin; it’s understanding which filter hooks the core plugin exposes and what to write at each one.
This guide walks through what WooCommerce produces out of the box, the
gap between that and a complete Product schema, and the
filter-hook pattern that fills the gap from a custom theme function or
a small site-specific plugin.
What WooCommerce ships by default
The current WooCommerce core (verified against version 9.x in 2026)
generates the following Schema.org Product markup automatically:
namedescription(the product short description, stripped of HTML)image(the featured image URL)skumpn(only when set as a custom field with that exact key)offerswithprice,priceCurrency,availability, andurlaggregateRatingandreview(only when WooCommerce reviews are enabled and the product has at least one)
That covers the validation bar but misses three properties that
weight heavily in AI agent surfacing: gtin13 (or gtin8), brand,
and category mapped to a Google product category. The first two
weren’t always required; in 2026 they’re decisive for whether an AI
agent treats a product as authoritative or speculative.
The gap
The core plugin’s output, side-by-side with what AI agents actually read for:
Three of those four (brand, gtin13, category) come from product data WooCommerce already stores — they just don’t make it into the auto-generated schema. The pattern below pipes them in.
The filter hook pattern
WooCommerce exposes a woocommerce_structured_data_product filter on
the JSON-LD output. Hooking it lets a theme or plugin extend the
schema without modifying the core plugin or maintaining a fork.
Drop this into the active theme’s functions.php (or a small
site-specific plugin):
add_filter( 'woocommerce_structured_data_product', function( $markup, $product ) {
// Brand — pulled from a product attribute named "brand" or a
// top-level brand taxonomy if the site uses one.
$brand_terms = wp_get_post_terms( $product->get_id(), 'product_brand', [ 'fields' => 'names' ] );
if ( ! is_wp_error( $brand_terms ) && ! empty( $brand_terms ) ) {
$markup['brand'] = [
'@type' => 'Brand',
'name' => $brand_terms[0],
];
}
// GTIN — stored as a custom field. Use barcode if your catalog
// populates that meta key; switch to your actual key.
$gtin = $product->get_meta( '_gtin' );
if ( $gtin ) {
$markup['gtin13'] = $gtin;
}
// Category — Google product taxonomy path, stored as a custom field.
$google_cat = $product->get_meta( '_google_product_category' );
if ( $google_cat ) {
$markup['category'] = $google_cat;
}
// Country of origin
$origin = $product->get_meta( '_country_of_origin' );
if ( $origin ) {
$markup['countryOfOrigin'] = [
'@type' => 'Country',
'name' => $origin,
];
}
return $markup;
}, 10, 2 );
The pattern is uniform: read from product meta or a taxonomy, add the
property to the $markup array, return it. WooCommerce serializes
the result into JSON-LD on the rendered page.
Variable products
The single biggest cause of malformed WooCommerce schema. Variable products have a parent product with multiple variations; the core plugin generates a single product-level schema block that doesn’t expose variant-specific data.
Two patterns work:
- Server-side variant detection. When a variant is selected via
URL parameter (
?attribute_pa_size=large), generate the schema for that specific variant rather than the parent. hasVariantarray. Keep the parent block but add ahasVariantarray enumerating each variation as aProductGroupmember with its ownsku,gtin13,offers, and attributes.
The first is closer to Shopify’s variant-URL pattern; the second matches Schema.org’s preferred model for catalogs where variants differ in more than just price. Across hundreds of WooCommerce stores, neither pattern ships in the default output — both require theme work.
Q&A pairs as a sibling FAQPage block
If the catalog has product-specific Q&A content, FAQPage is its own
Schema.org type — render it as a second JSON-LD block alongside the
WooCommerce-generated Product block. Don’t nest one inside the
other; Schema.org models them as peers.
The WooCommerce pattern uses ACF (or a similar custom-field plugin)
for the Q&A list, plus a wp_head hook that emits the FAQPage block:
add_action( 'wp_head', function() {
if ( ! is_product() ) return;
global $product;
$faq = get_field( 'product_faq', $product->get_id() ); // ACF repeater
if ( empty( $faq ) ) return;
$entities = array_map( fn( $pair ) => [
'@type' => 'Question',
'name' => $pair['question'],
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $pair['answer'],
],
], $faq );
$schema = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $entities,
];
echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>';
} );
The product_faq field is an ACF repeater with question (text) and
answer (textarea) sub-fields.
This is the rendering pattern. Note that testing in late 2025 suggests that AI agents may not consistently extract JSON-LD on direct page fetch; the value of structured Q&A markup appears to come through index and feed paths rather than agents reading the page directly.
Yoast SEO and RankMath
The two most-installed SEO plugins for WordPress both inject their
own schema. By default, Yoast and RankMath publish a graph that
includes WebSite, WebPage, Organization, BreadcrumbList, and
(on product pages) a Product block.
The conflict: WooCommerce core also publishes Product schema. With
both running, you get two Product blocks on the same page. AI
agents pick one at random.
The fix: disable WooCommerce’s core schema output if you’re using Yoast or RankMath, OR disable the SEO plugin’s product schema and extend the core. The plugin documentation has filters for both directions; pick one source of truth.
// Disable WooCommerce core product schema (use SEO plugin's instead):
add_filter( 'woocommerce_structured_data_product', '__return_empty_array' );
OR (more common):
In Yoast: Settings → Search Appearance → Content Types → Products
→ "Show Product as Schema piece" → off.
Where it breaks
Three failure modes that show up in production WooCommerce stores:
- Page caching plugins. WP Rocket, W3 Total Cache, and others serve cached HTML. If the schema is generated dynamically based on current price or inventory, the cached version goes stale. Solution: set the cache TTL short enough that schema updates within a few hours, or use the cache plugin’s “exclude product pages from cache” option.
- Multilingual setups (WPML, Polylang). The auto-generated schema
picks up the current language’s name and description, but
brand,category, andgtin13are global by default. The latter is what you want; the former two need translation. Test each language’s output through the Schema.org Validator. - Custom field plugins (ACF, Pods, MetaBox). These store data in
meta keys that may not match the keys WooCommerce expects. The
filter hook above uses
$product->get_meta( '_gtin' )— if your ACF field is namedgtin_valueand stored atgtin_valuenot_gtin, the property won’t render. Print$product->get_meta_data()once during development to confirm key names.
The contrarian take
Most WooCommerce SEO content recommends installing a dedicated schema plugin (Schema App, RankMath, Yoast). The dedicated plugins do work, but they introduce a second source of truth that conflicts with the core plugin’s schema and can drift from the catalog data.
The simpler pattern: extend the core’s schema via the filter hook, in a small site-specific plugin (not the active theme — the theme can change). The custom plugin is 50 lines, version-controlled, and survives theme switches and WooCommerce updates. SEO plugins are fine for sitemaps and meta tags; they’re overkill for product schema when the core plugin already publishes it.
Validation
The same three tools as Shopify:
- Google Rich Results Test (
search.google.com/test/rich-results) - Schema.org Validator (
validator.schema.org) - Manual JSON inspection — view-source on a product page, find the JSON-LD block, pretty-print, read.
Common WooCommerce-specific errors to look for:
- Two
Productblocks (WooCommerce + SEO plugin both injecting) offers.availabilityas a string instead of a Schema.org URLgtin13populated with a SKU instead of a real GTINaggregateRatingrendered when there are zero reviews (woocommerce_review_rating_requiredset to false)
What to ship this week
- Install or write the filter hook plugin above. Map the four load-bearing properties (brand, gtin13, category, countryOfOrigin) from existing product meta.
- Audit for double-rendered
Productblocks. Disable one source (core or SEO plugin). - Validate ten products end-to-end through the Rich Results Test.
- Wait two weeks. Re-check ChatGPT and Perplexity for the affected product types — lift is typically visible in a 14-day window.
For a 500–2,000 SKU WooCommerce store, this is a half-day project that closes the schema gap to where the core plugin meets AI agent expectations.