DEV Community

Max
Max

Posted on Originally published at storecanary.io

The Shopify variant bug that breaks Google Shopping listings

TL;DR

  • Search Console tracks two separate rich-result reports for products: Product snippets (cosmetic enhancements on a normal search result) and Merchant listings (eligibility for shopping carousels and knowledge panels). Merchant listings require a real Offer with a price above zero; an AggregateOffer is not enough.
  • On Shopify, any product with one or more variants renders as a ProductGroup, not a Product. The price sits inside each entry of hasVariant, one per variant, never at the top level. A check that only reads the top-level entity will report a missing price on a page that is marked up correctly.
  • Apps that inject their own Product block (reviews, rich snippets) next to the theme's ProductGroup describe the same item twice and can knock a product out of eligibility even when the theme markup is fine.

If you build or maintain Shopify storefronts for clients, you've probably seen "Merchant listings" and "Product snippets" sitting side by side in a client's Search Console account with no explanation of why a product is in one and not the other. It's a structured data mismatch that's easy to introduce during a completely ordinary change: a theme update, a reviews app install, a custom product template. The two reports look similar enough that most audits treat them as interchangeable, but eligibility for merchant listings depends on where the Offer object actually lives in the markup, and on Shopify that's not always where you'd expect. This piece covers what separates the two reports, where Shopify puts the price on variant products (and why that trips up naive checks), and a five-step process for diagnosing which report a client's catalog should be in before changing any code.

What's the actual difference between the two reports?

Google's Shopping reports and tools documentation defines both in a single line each. Product snippets is "a rich result report for Product snippet structured data found on your site," covering results shown "with visual enhancements, for example, product ratings and prices." Merchant listings is "a rich result report for Merchant listing structured data found on your site," and those listings "enable more full-featured search results and always include a price" and "might appear in a carousel of similar products from different sellers, or in a knowledge panel in search results."

A product snippet decorates the blue link a page already has. A merchant listing moves a page into a different class of result: the carousels and knowledge panels where shoppers compare sellers directly. For an ecommerce client, that second surface is the commercially relevant one, and it has the stricter entry condition.

Why does eligibility hinge on the Offer object?

Google's merchant listing documentation states the distinction directly:

"Product snippets accept an Offer or AggregateOffer but merchant listings require an Offer as the merchant has to be the seller of the product."

And a threshold worth checking for explicitly: "Unlike product snippets, merchant listing experiences require a price greater than zero."

Read together: an AggregateOffer (a price range, the shape a comparison page would use) is enough for a product snippet and not enough for a merchant listing. A price of zero doesn't just look wrong, it puts the item below the floor for the report that unlocks shopping surfaces. Everything else inside that Offer (priceValidUntil, identifiers, etc.) follows the same pattern: recommended rather than required, so a missing field produces a warning instead of an outright exclusion.

Where does Shopify actually put the price on a variant product?

This part is specific to Shopify rather than to structured data generally, and it's documented by Shopify itself. The structured_data Liquid filter, the one Dawn calls in sections/main-product.liquid as a single {{ product | structured_data }} line, describes its own output like this:

"Product objects are output as a schema.org Product if they have no variants, and a ProductGroup if they have one or more variants."

On a product with variants, the top-level entity on the page isn't a Product. It's a ProductGroup, and the offers live one level down, inside hasVariant, one offer per variant, each with its own price and its own ?variant= URL. Nothing sits at the top level declaring a single price, because with several variants there isn't one.

I checked four live Shopify storefronts, fetching each product page as Googlebot and parsing every application/ld+json block in the served HTML. Two emitted a ProductGroup with no top-level offers and the price inside each variant, matching the filter's documented behavior. The other two emitted no product structured data at all in the served HTML; one returned only a CollectionPage and an FAQPage on a product URL. None of the four emitted a plain Product with a price at the top, which is the shape most third-party audit scripts assume when they grep a page for "price".

The practical consequence: a check that only reads the top-level entity will report a page as missing price, availability, and identifiers when the page is marked up correctly. Worth confirming before acting on that kind of report.

How do variant relationships affect this?

Google treats variant relationships as something declared deliberately. Its product variant documentation asks for the ProductGroup class with variesBy, hasVariant, and productGroupID (described as "the identifier of the product group, also known as the parent sku"). It also sets a structural precondition Shopify satisfies by default: "The site must have the ability to preselect each variant directly with a distinct URL (using URL query parameters)."

It connects this directly to the topic at hand: "Adding this markup also makes your products eligible for display with variant information in merchant listing experiences." Variant markup feeds directly into merchant listing eligibility rather than sitting beside it as a separate concern.

In the stores checked, a product whose only variant was the default one still emitted a ProductGroup, just with no variesBy property: a group that varies by nothing.

How do you read the combination of reports a client has?

The most useful diagnostic in Search Console isn't either report alone, it's which of three related reports exist at all. Google documents a third one, Merchant opportunities, which "shows recommendations for improving how your online shop appears on Google" and appears "if you have an eligible site that sells physical goods."

If Merchant opportunities is present and the two rich-result reports aren't, Google states the cause is that either "Google hasn't found structured data on your site, or Google found structured data, but the sample data used by Search Console to generate reports doesn't contain any entity data." That second clause matters: the reports are built from a sample, so an absent report isn't proof that no page on the site carries the markup.

The reverse combination has its own meaning too. If the product reports exist and Merchant opportunities doesn't, Google "found structured data on your site, but doesn't recognize your site as an online shop that sells physical goods," a statement about how the whole site is classified, not about any single product page.

How do you audit a client's store in five steps?

  1. Read which reports exist at all. Open the Shopping section of Search Console and note which of Merchant listings, Product snippets, and Merchant opportunities are present. The combination is the diagnosis; an absent report carries as much signal as a present one.
  2. View source on a product with variants. Read the JSON-LD in the served HTML, not in a rendered DOM inspector. Check whether the top-level entity says Product or ProductGroup.
  3. Find where the offer actually sits. If it's a ProductGroup, the offers are inside hasVariant, one per variant. Confirm each carries price, priceCurrency, and availability, and that no price is zero.
  4. Count the product entities on the page. List the top-level type of every application/ld+json block. A page declaring a ProductGroup from the theme and a separate Product from an app is describing the same item twice.
  5. Compare the report against catalog size. Put the number of valid items in the Merchant listings report next to the number of products actually sold. A large gap points at a subset of products whose markup differs from the rest, which is more tractable than a store-wide problem.

What actually causes this, and how do you fix each cause?

No product markup in the served HTML. Check this first; it's total rather than partial. If a product URL returns no Product or ProductGroup at all, nothing else matters until that's fixed. Usually the theme's markup was removed during customization, or the page is assembled client-side. Google's reports are built from what the crawler receives, so markup that only exists after JavaScript runs is a real risk.

An offer that can't clear the merchant-listing bar. A missing price, a price of zero, or an AggregateOffer where an Offer is required all keep an item out of the merchant listing report while leaving it eligible as a product snippet. That asymmetry is why a store can look fine in one report and be absent from the other.

Two entities describing one product. When a review app, a rich-snippet app, or a theme section adds its own Product block beside the theme's ProductGroup, the page makes two claims about one item. Find the second block before editing the first; removing an app's output is usually easier than making the theme agree with it. If the two blocks disagree about price or availability, the consequences reach past these reports.

Markup that's present but doesn't parse. A single stray character voids an entire JSON-LD block, and Search Console reports that separately, under its own label ("Unparsable structured data"). If a client's reports are empty and you expected otherwise, rule this out before assuming an eligibility problem.

Why does this keep resurfacing on client sites?

None of the changes that trigger this look like changes to Google. Installing a reviews app, letting a theme update land, retiring the last variant of a product, or having a different developer customize a product template are all ordinary operations, and each can move a page across the line between the two reports. Nothing in Shopify admin surfaces the shape of a store's structured data, and Search Console shows the consequence weeks later, in a report with no alert attached. Making the fix once doesn't close the loop: the same routine decisions will trip this again on the next theme update or app install, and nothing in the stack flags it automatically.

Have you run into this on a client's store, a ProductGroup with no top-level offer, or two conflicting Product blocks on the same page? What caught it: a manual audit, or something automated?

The full version of this article, with screenshots and ongoing updates, lives on the StoreCanary blog.

Top comments (0)