Schema.org Product defines more than
sixty properties directly, plus more inherited from Thing. Most
Shopify themes ship four. The gap between four and the dozens that
matter for AI parsing is the gap between “present” and “parsed.”
This guide walks through the properties that matter, the Liquid that
produces them correctly, and the seven failure modes that show up
most often in production catalogs.
This is a reference, not a tutorial. If a property does not change behavior in any of the major AI agents or Google rich results, it is not in this guide. If something in this guide contradicts schema.org/Product, schema.org wins — the spec is the source of truth.
The product-schema flow on a typical Shopify storefront looks like this — one source of truth in Liquid, four downstream destinations:
Where the markup actually lives on a rendered Shopify product page — two readers, two places they look:
The four every theme ships
Most stock Shopify themes (Dawn, Sense, Refresh, and the major paid
ones — Impulse, Prestige, Empire) ship Product markup with these
four properties:
namedescriptionimageoffers(usually withpriceandpriceCurrencyonly)
This is enough to validate. It is not enough to parse usefully. Every major AI agent can read this markup; none give the product a discoverability lift over a competitor with fuller markup.
The properties that move surfacing
The properties that are load-bearing for AI agent behavior, in rough order of impact:
sku and gtin13 / gtin8 / mpn
Universal product identifiers. AI agents use these to deduplicate the same product across multiple stores and to verify that the product is authoritative. Catalogs without identifiers compete with their own resellers and lose.
Private label catalogs are the most common omission. Apply for GTINs through GS1; do not invent them.
{%- assign variant = product.selected_or_first_available_variant -%}
{
"@context": "https://schema.org",
"@type": "Product",
"sku": {{ variant.sku | json }},
{%- if variant.barcode -%}
"gtin13": {{ variant.barcode | json }},
{%- endif -%}
"mpn": {{ variant.metafields.product.mpn | json }},
...
}
brand
Always include. Brand is a documented
input for branded-product queries across Google Shopping, Bing
Shopping, and the indexes that AI surfaces query. A missing brand
removes the catalog from queries that name a brand alongside a
product type.
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
}
offers (full)
The offers property is where most schema fails silently. The minimal
version ships with price and priceCurrency. The full version
includes:
availability— prefer the full IRI form (https://schema.org/InStock) for maximum portability; Google also accepts the bare token (InStock).priceValidUntil— recommended for sale prices; if set to a past date, Google may suppress the rich snippet.itemCondition—https://schema.org/NewConditionfor new products.seller— the merchant Organization.url— the canonical product URL.priceSpecification— for products with tiered or unit pricing.
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ variant.price | divided_by: 100.0 | json }},
"priceValidUntil": "{{ 'now' | date: '%Y' | plus: 1 }}-12-31",
"availability": {%- if variant.available -%}
"https://schema.org/InStock"
{%- else -%}
"https://schema.org/OutOfStock"
{%- endif %},
"itemCondition": "https://schema.org/NewCondition",
"seller": {
"@type": "Organization",
"name": {{ shop.name | json }}
}
}
aggregateRating and review
Rich results trigger only when there are real reviews. Faking ratings to game rich results gets the markup ignored at best and the page penalized at worst. If the product has fewer than five real reviews, omit these properties entirely.
{%- if product.metafields.reviews.rating_count > 4 -%}
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ product.metafields.reviews.rating | json }},
"reviewCount": {{ product.metafields.reviews.rating_count | json }}
},
{%- endif -%}
Google narrowed which review markup triggers rich results in 2024 —
self-serving reviews stopped firing, and AggregateRating is now only
valid on the page where the product is sold (not on hub or category
pages that aggregate reviews from elsewhere).
category
Maps the product to a Google product category. Required for Google Merchant Center; optional but valued by other AI agents. Use the full taxonomy path, not just the leaf node.
"category": "Apparel & Accessories > Clothing > Outerwear > Coats & Jackets"
Variant handling
The single biggest cause of malformed Shopify product schema. Two patterns work; mixing them does not.
The short version: on a default product page (no variant in the URL),
the schema represents the parent product with hasVariant listing
each variant as a ProductGroup member. On a variant-specific URL
(?variant=12345), the schema represents the specific variant.
Most stores ship the parent-product version on every URL, which is wrong: the agent reads the parent, sees no specific size or color, and treats the page as a category-level result rather than a specific recommendation. This is a common variant-handling failure pattern in Shopify catalogs.
Image properties
image accepts a single URL, an array of URLs, or ImageObject
entries. The richer the markup, the better the multi-modal models do.
"image": [
{%- for img in product.images -%}
{{ img | img_url: '2048x2048' | prepend: 'https:' | json }}
{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
]
For multi-modal lift, use ImageObject with explicit dimensions:
"image": [
{%- for img in product.images -%}
{
"@type": "ImageObject",
"url": {{ img | img_url: '2048x2048' | prepend: 'https:' | json }},
"width": {{ img.width }},
"height": {{ img.height }}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
]
Q&A pairs as a sibling FAQPage block
If the catalog has product-specific Q&A content (sizing, fit,
compatibility, use-case clarification), FAQPage is its own
Schema.org type — render it as a second JSON-LD block alongside the
Product block, not nested inside it.
The Shopify pattern: store Q&A pairs in a metafield (a list of
metaobjects with question and answer fields), then render in
Liquid:
{%- if product.metafields.product.faq.value.size > 0 -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{%- for entry in product.metafields.product.faq.value -%}
{
"@type": "Question",
"name": {{ entry.question | json }},
"acceptedAnswer": {
"@type": "Answer",
"text": {{ entry.answer | json }}
}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
]
}
</script>
{%- endif -%}
The metafield definition (Settings → Custom data → Products → Add
definition) is product.faq with type “list of metaobjects.” Each
metaobject has a question (single line text) field and an answer
(multi-line text) field.
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 (Google’s index → AI Overviews, etc.) rather than agents reading the page directly.
Seven failure modes
In rough order of frequency, the patterns that show up most often when auditing Shopify product schema in the wild:
-
offers.priceas a string. Shopify’smoneyfilter outputs formatted currency ("$45.00"). Schema needs a number. Usevariant.price | divided_by: 100.0 | json. -
availabilityas a free-form string. “InStock” is invalid;https://schema.org/InStockis correct. Same forOutOfStock,PreOrder,BackOrder. -
Multiple Schema.org
Productblocks per page. Common when a theme ships product schema and an app injects more. AI agents pick one at random. Audit withview-sourceand the Schema.org Validator. -
Variant schema on parent URL. Page is the parent product page; schema describes one specific variant. Reads as a malformed product to most agents.
-
skushared across variants. Each variant should have a unique SKU; some catalogs reuse the parent SKU on every variant. Breaks inventory parsing in GMC. -
Stale
priceValidUntil. Set to a date in the past; rich results stop firing. Use a dynamic Liquid expression, not a hardcoded date. -
descriptioncontaining HTML. Schema’sdescriptionfield is plain text. Stripping HTML on the way in:{{ product.description | strip_html | json }}.
The contrarian take
Most schema content treats Schema.org Product as a static reference —
write the markup, validate, ship, done. The reality: schema is dynamic
infrastructure. As Schema.org publishes new properties, as Google
narrows what triggers rich results, as AI agents re-tune which
properties they weight, the markup that scored well last quarter
underperforms this quarter without changing.
The maintenance pattern: re-validate the catalog quarterly, audit against the current Schema.org spec annually, and treat the markup as a living layer rather than a one-time setup task.
Where this still won’t be enough
Three cases:
- Headless Shopify. Schema must render server-side or the agent doesn’t see it. Client-rendered schema in a JavaScript framework gets missed by most agents.
- B2B with quote-based pricing. Schema requires a price; quote-based
catalogs need a workaround using
priceSpecificationwith"pricing"set to “Contact for pricing.” Some agents handle this; many don’t. - Multi-currency with Shopify Markets. The default JSON-LD ships the storefront’s primary currency. AI agents reading from a different region see the wrong price.
Validation
Three tools, in order of strictness:
- Google’s Rich Results Test (
search.google.com/test/rich-results) — tells you whether rich results will trigger. Most useful for verifying your changes will show up in Google. - Schema.org Validator (
validator.schema.org) — tells you whether the markup conforms to the spec. Useful for catching property errors Google’s tester ignores. - Manual JSON inspection. Pretty-print the JSON-LD from the rendered
page, read it. The errors that pass both validators usually fall out
of a manual read —
nullvalues that should be omitted, malformed nested objects, character-encoding issues.