WooCommerce variations and SEO: variation URLs, canonical links and the price in structured data
A WooCommerce variable product has one address. Each variation is that address with attribute parameters added, and it carries a canonical link back to the product, so it is not a duplicate to fix. The product's markup gives a price range. A variation's address gives that variation's price.
- By
- WP Ministry
- Published
- Tested on
- WordPress 7.1.3, WooCommerce 11.2.0, PHP 8.3.35
In short
- A variable product has one address and one line in the sitemap. A variation is that address with parameters such as ?attribute_pa_size=large on the end.
- Every variation address answers 200 with a canonical link to the plain product address and no noindex. Nothing there needs blocking.
- On the product's own address, variations at different prices are described as an AggregateOffer with a lowPrice and a highPrice.
- Google's documentation says a merchant listing requires an Offer, and says not to use AggregateOffer for a set of variants.
- On an address that names every attribute, WooCommerce 11.0 and later prints one Offer at that variation's price, with inProductGroupWithID.
- WooCommerce prints no ProductGroup and no hasVariant. Giving each variation its own SKU is a setting. The group markup takes an extension or code.
A WooCommerce variable product is one product with one address. Each variation, a size or a color, is that same address with attribute parameters on the end, such as ?attribute_pa_size=large. Every one of those addresses carries a canonical link back to the plain product address. None is in the sitemap, and none asks to be left out of search. They are not a duplicate-content problem to fix.
The price is the part worth a look. When variations cost different amounts, the product's own address describes the price as a range, in a form (AggregateOffer) that Google's documentation accepts for one kind of product result and not for the other. An address that names a single variation gives that variation's one price. This page shows each of these on a store, sets it beside what Google's documentation asks for, and separates what is a setting from what needs code.
One product, one address
Take a store that sells a tote in two sizes, Small at $20 and Large at $30. WooCommerce gives it these addresses.
| Address | |
|---|---|
| The product | https://example.com/product/canvas-tote/ |
| Small | https://example.com/product/canvas-tote/?attribute_pa_size=small |
| Large | https://example.com/product/canvas-tote/?attribute_pa_size=large |
The word after attribute_ is the attribute's name, and it comes in two forms.
pa_sizeis an attribute made under Products, then Attributes, which the whole store shares. Its value in the address is the term's slug:large.color, with nopa_, is an attribute added on the one product. Its value is the option as it was typed:?attribute_color=Red.
A product with two attributes used for variations has both in each variation's address: ?attribute_pa_size=large&attribute_color=Red.
A variation is not a page of its own. WooCommerce stores each one as a record under the product, of a type it registers as not public. Asking the store for a variation by its own name answers 404.
So where do the addresses with parameters come from? Not from the product form: choosing a size in WooCommerce's own form changes the page in place and leaves the address as it was. WooCommerce builds the longer address wherever it links to one variation and not to the product. The product's name in a line of the cart links there. So does anything that asks WooCommerce for a variation's address, such as a product feed that lists each variation as an item. Asked for directly, the page arrives with that variation already chosen in the form.
What a variation's address answers
/product/canvas-tote/ | /product/canvas-tote/?attribute_pa_size=large | |
|---|---|---|
| Status | 200 | 200, with no redirect |
| Canonical link | /product/canvas-tote/ | /product/canvas-tote/ |
| Robots tag | max-image-preview:large | max-image-preview:large |
| In WordPress's sitemap | Yes | No |
| Option chosen in the form | None, unless the product has a default | Large |
| Price in the page as the server sends it | $20.00 – $30.00 | $20.00 – $30.00 |
| Price in the structured data | A range, 20.00 to 30.00 | One price, 30.00 |
These are the two lines in the <head> of the address that ends ?attribute_pa_size=large.
<meta name='robots' content='max-image-preview:large' />
<link rel="canonical" href="https://example.com/product/canvas-tote/" />- The canonical link comes from WordPress, not from WooCommerce. WordPress prints one on every single post, page or product, built from the item's own permalink. What follows the question mark plays no part. An address with a value no variation has, such as
?attribute_pa_size=nonsense, answers 200 with the same canonical link. - The robots tag is the one WordPress puts on every page of a site that is open to search engines. It allows large image previews and blocks nothing. What the "Discourage search engines" box does covers the other value it can have.
- The sitemap lists each product once. WordPress makes sitemaps for content types that are public, and a variation is not one. On the example store,
/wp-sitemap-posts-product-1.xmlholds the plain address of each product and no address with a parameter. If an SEO plugin provides the sitemap on your store, read that one: the WordPress sitemap at wp-sitemap.xml shows how to tell. - The price a visitor reads changes after the page loads. The HTML the server sends shows the range on both addresses. Each variation's price is in the form's data, and WooCommerce's script shows the chosen one once the page is open in a browser.
What the structured data says about the price
WooCommerce prints a block of JSON-LD on every product page. WooCommerce schema markup covers the whole block and how to test it. This section is about one part of it, offers, on a variable product. The examples are WooCommerce's real output on the example store, cut down to the properties that matter here.
Variations at different prices
On the product's own address, the tote's two prices become a range.
{
"@type": "Product",
"name": "Canvas Tote",
"url": "https://example.com/product/canvas-tote/",
"sku": 123,
"offers": [
{
"@type": "AggregateOffer",
"lowPrice": "20.00",
"highPrice": "30.00",
"offerCount": 2,
"availability": "https://schema.org/InStock",
"url": "https://example.com/product/canvas-tote/",
"priceCurrency": "USD"
}
]
}lowPrice and highPrice are the cheapest and the dearest variation. offerCount is the number of variations that have a price.
Every variation at the same price
A mug sold in two colors, both at $12, has no range to give. WooCommerce prints an ordinary Offer with "price": "12.00" on the product's own address, as it does for a simple product.
An address that names every attribute
Ask for the tote with its one attribute named, and the offer changes.
{
"@type": "Product",
"name": "Canvas Tote",
"url": "https://example.com/product/canvas-tote/",
"sku": 123,
"inProductGroupWithID": "123",
"offers": [
{
"@type": "Offer",
"availability": "https://schema.org/InStock",
"url": "https://example.com/product/canvas-tote/?attribute_pa_size=large",
"price": "30.00",
"priceCurrency": "USD"
}
]
}- The offer is a single
Offerat that variation's price, with that variation's own stock status as itsavailability. - The offer's
urlis the address with the parameters. The product'snameandurlstay those of the parent product. - The product gains
inProductGroupWithID. It holds the parent product's SKU or, when there is none, its ID number. skuis the variation's own SKU if it has one. Here it has none, so both values are the parent's ID. That matters, and the section on settings comes back to it.
WooCommerce has done this since version 11.0. Its changelog for that release says the store now emits a single Offer with the exact variation price when a variation's address is requested, in place of the parent's price range. The comment in the code gives the case it was written for: a variation's address submitted to a product feed, where a range in the landing page's markup led to price mismatch disapprovals in Google Merchant Center. On an older WooCommerce, every address of the product prints the range.
The single Offer appears only when the address picks out exactly one variation.
- Every attribute the product uses for variations has to be in the address. A shirt that varies by size and color, asked for with
?attribute_pa_size=largealone, prints the range. - Each value has to match as it is stored.
?attribute_color=reddoes not match an option typed as "Red", and the page prints the range. - One published variation with a price has to match, and only one. Where a variation set to "Any" for an attribute overlaps another, so that two match the address, the page prints the range.
- The product's "Default Form Values" do not count. With Large set as the default, the plain address arrives with Large chosen in the form and still prints the range. Only the address is read.
| Address | Variations' prices | What offers holds |
|---|---|---|
| The product's own | Differ | AggregateOffer with lowPrice and highPrice |
| The product's own | All the same | Offer with the one price |
| Names every attribute and matches one variation | Either | Offer with that variation's price, and inProductGroupWithID on the product |
| Names only some attributes, or a value nothing matches | Differ | AggregateOffer with lowPrice and highPrice |
What Google's documentation says about it
Google describes two kinds of product result, product snippets and merchant listings, and the schema guide sets out what each requires. Three of its pages bear on a variable product.
On Offer against AggregateOffer. The merchant listing page says this of the offers property: "Product snippets accept an Offer or AggregateOffer but merchant listings require an Offer". The reason it gives is that the merchant has to be the seller of the product. Inside that Offer it requires a price and a currency. So the range on a variable product's own address meets what a product snippet accepts and does not meet what a merchant listing requires.
On what AggregateOffer is for. The product snippet page defines it as an aggregation of other offers, with a product sold by several merchants as its example, and says not to use it to describe a set of product variants. That is what WooCommerce uses it for. The same page says that the price drop enhancement needs an Offer, not an AggregateOffer.
On variants. The product variant page describes a ProductGroup that stands for the parent product, with variesBy naming what differs (size, color), hasVariant holding a Product for each variant, and productGroupID holding the parent's SKU. It says this markup makes products eligible to be shown with variant information in merchant listings. Of the ProductGroup properties, only name is required and the rest are recommended. The page's technical guidelines are stricter, and this is how a store with no extension measures against them.
| Google's guideline for variants | WooCommerce, with no extension |
|---|---|
| Each variant can be preselected at a distinct address, using query parameters | Yes. The option is chosen in the HTML, and the script then shows its price and image |
| One canonical address for the whole group, typically the address with no variant selected | Yes, from WordPress's canonical link |
| Product markup in the HTML the server sends, which the page recommends | Yes |
An ID for the group, in inProductGroupWithID or productGroupID | Only on an address that names every attribute |
A unique ID for each variant, such as sku or gtin | Only when each variation has its own SKU or GTIN |
A ProductGroup with hasVariant and variesBy | No |
WooCommerce prints no ProductGroup, hasVariant, variesBy, productGroupID or isVariantOf on any address, and no size or color on the product.
Are variation addresses duplicate content?
They are the same page at several addresses, and the store already says which address counts. Google's guide to URL structure for ecommerce sites gives this exact case: where optional query parameters identify variants, use the address with the parameter left out as the canonical. A WooCommerce store does that with no setting touched.
Google's documentation on canonicals adds three things worth knowing.
- Some duplicate content on a site is normal and is not a violation of its spam policies.
- A canonical link is a strong signal, and still a hint. Google can choose a different address than the one you name. On a variable product the link, the sitemap and the shop's own listing all name the plain address, so they agree.
- Robots.txt and noindex are the wrong tools here. Google says not to use robots.txt for canonicalization, since a blocked address can still be indexed without its content, and does not recommend noindex for choosing between pages of one site. A blocked variation address is also one where Google cannot read the single
Offer.
When Google treats such an address as a duplicate of the product, Search Console's Page indexing report lists it among the pages that are not indexed. Google's help for that report says "Not indexed" is not necessarily bad, and that a page listed as "Alternate page with proper canonical tag" points correctly to its canonical and needs nothing done. A list of ?attribute_ addresses under that reason is the canonical link doing its job.
Addresses made by filters and sorting, such as ?orderby=price, are a different matter with a different answer: see WooCommerce filter URLs indexed in Google.
When the price in search can differ from the price on the page
- Never, if every variation costs the same. The page and the markup give one price everywhere.
- On the product's own address, the markup gives the range and nothing else. That is the address Google is told to index. Whatever a result shows from a
lowPriceof 20.00, the shopper who chooses Large pays 30.00. The markup and the page agree with each other. What one price cannot do is describe both sizes. - With a default set, the page and the markup part ways a little. A visitor to the plain address sees Large chosen and its price shown. The markup still says 20.00 to 30.00.
- On a variation's address, the markup gives that variation's price. This is the page a feed item for Large can lead to. On WooCommerce before 11.0 its markup gave the range, which is the mismatch that release fixed.
- A price that Merchant Center reports as mismatched is a subject of its own. Mismatched value (page crawl) for price in Merchant Center covers that report.
Check what your own store answers
These commands read what the store sends, which is what a crawler gets. The first two need WP-CLI on the server; how to use WP-CLI covers that. The rest run anywhere curl and grep are installed. Put your own addresses in place of the example ones, and an administrator's username in place of admin.
Step 1: List your variable products
Each line is a product's ID number, its name and its one address.
bashwp wc product list --user=admin --type=variable --fields=id,name,permalink --format=csvStep 2: List one product's variation addresses
Put a product's ID in place of
123.The last column is the address WooCommerce builds for each variation. The
skucolumn shows the product's SKU, or nothing, for a variation that has none of its own.bashwp wc product_variation list 123 --user=admin --fields=id,sku,price,permalink --format=csvStep 3: Ask for one and read the status
It should print
200. Keep the quotes around the address, since it can contain&.bashcurl -s -o /dev/null -w "%{http_code}\n" "https://example.com/product/canvas-tote/?attribute_pa_size=large"Step 4: Read its canonical link and robots tag
The canonical link should name the plain product address, and the robots tag should not say
noindex. If the tag does saynoindex, start with the "Discourage search engines" box. If the canonical link names any other address, a plugin or the theme is changing it.bashcurl -s "https://example.com/product/canvas-tote/?attribute_pa_size=large" | grep -Eio "<link rel=.canonical.[^>]*>|<meta name=.robots.[^>]*>"Step 5: Read the sitemap's product addresses
Each product should be there once, with no
attribute_in any line.bashcurl -s https://example.com/wp-sitemap-posts-product-1.xml | grep -o "<loc>[^<]*</loc>"Step 6: Read the offer on the product's own address
For the tote it prints four lines:
"sku":123,"@type":"AggregateOffer","lowPrice":"20.00"and"highPrice":"30.00".bashcurl -s "https://example.com/product/canvas-tote/" | grep -o '<script type="application/ld+json"[^>]*>[^<]*' | grep -Eo '"@type":"(Aggregate)?Offer"|"(sku|gtin|inProductGroupWithID|lowPrice|highPrice|price)":"?[^",}]*"?'Step 7: Read it again on a variation's address
Run the same command with one of the addresses from the second step between the quotes. For the tote in Large it prints these lines.
The price is there twice because WooCommerce states it once inside a
priceSpecificationand once on the offer itself.text"sku":123 "inProductGroupWithID":"123" "@type":"Offer" "price":"30.00" "price":"30.00"
If a variation's address prints AggregateOffer on WooCommerce 11.0 or later, go through the four conditions above. If they hold, look at what sits between WooCommerce and the visitor, such as a page cache that stores one copy of a page for every query string, or a plugin that rewrites the product's markup.
What you can change
What is fine as it is
- The addresses with parameters. They answer, they name their canonical, and they are how one variation is linked to. Do not block them in robots.txt, add noindex to them, or redirect them to the plain address. A redirect would remove the page that preselects a variation, which is what Google's guidelines for variants ask a store to have.
- Their place in Search Console's "not indexed" list, as above.
- A product whose variations all cost the same. It already prints a single
Offer.
What is a setting
- Give each variation its own SKU. Edit the product, open the Variations tab, open each variation and fill in "SKU". WooCommerce's documentation says a variation with none takes the product's. In the markup that means every variation address prints the same
sku, equal to the group's ID, where Google's guidelines ask for an ID unique to each variant. With the product's SKU set toTOTEand the Large variation's toTOTE-L, the address for Large prints"sku":"TOTE-L"beside"inProductGroupWithID":"TOTE". - Enter a variation's GTIN on the variation, if it has a barcode. On a variation's address WooCommerce leaves out the parent product's GTIN and prints the variation's own, when it has a valid one. Missing field "brand" and "No global identifier provided" in WooCommerce covers that field.
- Keep WooCommerce current. The single
Offeron a variation's address needs 11.0 or later. How to update WooCommerce safely covers doing it without breaking the store. - Link to the plain address from your own menus and pages. Google's advice is to link to the address you consider canonical.
What needs an extension or code
- A
ProductGroupwithhasVariantandvariesBy. WooCommerce has no setting for it. It comes from a plugin whose documentation says it outputs product variant markup, or from code on the filter WooCommerce passes its product markup through,woocommerce_structured_data_product, which the schema guide shows in use. Either way, each variant in the group needs its ownOffer, ID and address, so enter the SKUs first. - A single
Offeron the product's own address when prices differ. WooCommerce has no setting for this either. A filter can replace the range, but with which price? Google requires structured data to be a true representation of the page, and the page shows a range. The route Google documents for a product in several variants is theProductGroupabove, with oneOfferper variant. - Product data in Google Merchant Center. It gets there through an extension such as Google for WooCommerce, whose documentation says it syncs a store's products to Merchant Center automatically. Merchant Center's help says variants in product data are grouped with an item group ID, and requires one for free listings of variants. Since 11.0, the address WooCommerce builds for a variation states that variation's one price in its markup.
Should each variation be its own product?
Google's variant documentation covers both designs and calls them single-page and multi-page. It does not rank one above the other. On WooCommerce the choice is between one variable product and several simple products.
| One variable product | A simple product for each variant | |
|---|---|---|
| Addresses in the sitemap | One | One for each |
| Canonical link | Every variation points to the product | Each product points to itself |
| Markup on the indexed address | A range, when prices differ | A single Offer with one price |
| Name, description and image in the markup | The parent's | Each product's own |
| Choosing a size | On one page | On separate pages, unless something links them |
| Reviews | Gathered on one product | Split across the products |
| Stock and prices to keep | In one place | In several |
Two things are easy to miss. Simple products are not tied together by anything in WooCommerce's markup, so the group Google's documentation describes still needs adding. And products that differ only in a size are very similar pages: Google's documentation says it clusters pages whose main content is very similar and picks one as the canonical, so it may treat some of them as duplicates of one another.
Neither design is the right one for every store. The question to put to your own catalog is whether a variant is something shoppers look for by name and that has a description and a picture of its own. That is a judgment about your products and your customers, and nothing in Google's documentation settles it for you.
When to get help
- Ask the plugin's support if an SEO or schema plugin is changing the canonical link or the product markup and you cannot see why.
- Hand it over if Search Console reports a price or offer problem on variable products and the checks above do not explain it. A WordPress SEO audit from WP Ministry is a one-time, written audit of what stands between a WordPress or WooCommerce site and a search engine, and for a store it includes what a product page tells a search engine about its price, stock and reviews.
Common questions
Are WooCommerce variation URLs duplicate content?
They are the same page at more than one address, and each carries a canonical link to the plain product address. Google's guide to URLs for ecommerce sites recommends that arrangement for variants identified by query parameters. Its documentation also says some duplicate content on a site is normal and not a violation of its spam policies.
Should I block attribute URLs in robots.txt or noindex them?
No. Google's documentation says not to use robots.txt for canonicalization and does not recommend noindex for it. The canonical link already does the job. Blocking the addresses would also stop Google reading the single-price markup that a variation's address carries.
Why does my variable product have an AggregateOffer and not an Offer?
Because its variations have different prices, and WooCommerce describes them on the product's own address as a range from the lowest to the highest. A product whose variations all cost the same gets an Offer. So does any address that names every attribute of one variation, on WooCommerce 11.0 or later.
Does setting default form values change what Google reads?
Not in the markup. With a default set, the plain address arrives with that variation chosen in the form, and the structured data still gives the range. WooCommerce reads the variation from the address and from nothing else.
Are variations in the sitemap?
No. WordPress's sitemap lists each product once, at its plain address. A variation is stored as a record under its product and is not a public page, so it has no entry.
Does Google show a separate result for each variation?
Nobody outside Google can say what it will show. What the store hands over is one address for each product that asks to be indexed, with every variation's address pointing to it. Google's documentation says ProductGroup markup makes products eligible to be shown with variant information in merchant listings, and WooCommerce does not print that markup without an extension or code.
- Error fixHow to fix WooCommerce payment gateway errors
- ResourceWooCommerce checkout is down: a runbook
- Cost guideWooCommerce maintenance cost: why a store costs more, and what the extra should buy
- ResourceWooCommerce sale readiness checklist: Black Friday or any big sale
- Error fixHow to fix a WooCommerce checkout that is not working
- GuideHow to maintain a WooCommerce store: before every update, every week, every month and before a sale

