Skip to content
Published Authored byBilly Reiner

Schema · How-to

Shopify Offer schema

Offer is the transaction sub-object inside Product3. On Shopify, the theme's auto-emitted Product block already contains an Offer with price, priceCurrency, availability, and url1. What's missing from the auto-emission and worth adding: priceValidUntil (for sale prices), itemCondition, seller (the merchant Organization), hasMerchantReturnPolicy, and shippingDetails. Google's canonical Product doc lists all of these as required or strongly recommended for Merchant listing eligibility5.

Offer is one of the seven product fields Shopify's own Catalog optimisation doc names as material for AI platform consideration — "Price, availability, and key features"2. The Offer object is where price and availability live in JSON-LD, which means Offer is the schema field AI shopping engines hit first when deciding whether a Shopify product is buyable, in stock, and shippable to the user.

What Offer schema is

Per Schema.org v30.0, Offer is 'an offer to transfer some rights to an item or to provide a service.' The default business function is selling. On a Shopify PDP, Offer is the JSON-LD sub-object inside Product that describes the transaction itself — price, priceCurrency, availability, itemCondition, url, seller, and the sub-objects shippingDetails (OfferShippingDetails) and hasMerchantReturnPolicy (MerchantReturnPolicy).

Mental model: Product is the item; Offer is the transaction. The same physical book has one Product entity but could carry multiple Offer entities — new at $25, used at $12, library copy at $0. On Shopify, a product with multiple variants (size, colour) is typically modelled as one Product with one Offer (the default-variant price). For finer-grained variant offers, use ProductGroup + hasVariant where each variant Product has its own Offer.

What Shopify themes emit

The Offer sub-object inside Shopify's auto-emitted Product block typically ships with @type Offer, price (product.price divided by 100), priceCurrency (shop.currency), availability (mapped to https://schema.org/InStock or OutOfStock based on inventory), and url (the canonical product URL). Some themes also emit sku and itemCondition. What Shopify themes do NOT reliably emit on the Offer: priceValidUntil (the sale-end date), seller (the merchant Organization), hasMerchantReturnPolicy, shippingDetails, eligibleRegion.

For the auto-emitted Offer to be enough, the store would need to have no sales (no priceValidUntil), no Merchant Center ambitions (no hasMerchantReturnPolicy / shippingDetails), and no international shipping concerns (no eligibleRegion). For most Shopify stores, the auto-emitted Offer is a starting point, not an endpoint.

Offer fields and what they map to on Shopify

Per Schema.org v30.0, the Offer properties Shopify merchants care about are: price (Number/Text — Shopify's product.price divided by 100), priceCurrency (Text, ISO 4217 — shop.currency), priceValidUntil (Date — for sale prices, when the sale ends), availability (ItemAvailability enumeration), itemCondition (OfferItemCondition — typically NewCondition), url (the PDP), seller (Organization — the merchant), sku (Text), gtin (Text — Shopify's product.barcode), shippingDetails (OfferShippingDetails), hasMerchantReturnPolicy (MerchantReturnPolicy).

  • price — required. Shopify stores prices in cents; divide by 100.0 in Liquid.
  • priceCurrency — required. Use shop.currency (ISO 4217: USD, EUR, GBP, etc.).
  • priceValidUntil — recommended when a sale is active. Format YYYY-MM-DD. Use the sale-end metafield or the campaign deadline.
  • availability — required. See the next section for the enumeration map.
  • itemCondition — recommended. Almost always https://schema.org/NewCondition on Shopify; use UsedCondition or RefurbishedCondition only when accurate.
  • url — required. {{ shop.url }}{{ product.url }}.
  • seller — recommended. Reference the Organization in theme.liquid by @id.
  • sku — recommended. Already auto-emitted on most themes.
  • gtin / gtin12 / gtin13 / gtin14 — recommended. Shopify's product.barcode field. See the GTIN leaf.
  • hasMerchantReturnPolicy — recommended. Schema.org MerchantReturnPolicy sub-object. See the dedicated leaf.
  • shippingDetails — recommended. Schema.org OfferShippingDetails sub-object. See the dedicated leaf.

The availability enumeration

Schema.org defines a fixed set of availability values: InStock, OutOfStock, PreOrder, BackOrder, SoldOut, Discontinued, LimitedAvailability. On Shopify, the mapping from inventory state to availability is straightforward but worth getting right. product.available = true with inventory > 0 maps to InStock. product.available = false maps to OutOfStock. PreOrder applies when product.available = true but the product ships in the future. BackOrder applies when product.available = true but inventory_quantity ≤ 0 with continue selling out of stock enabled.

priceValidUntil and Shopify sales

priceValidUntil is the Date after which the current price is no longer valid. Google's Product documentation strongly recommends it whenever a sale price is shown. On Shopify, the cleanest pattern is a product metafield (e.g. custom.sale_ends_at) that the merchant sets when configuring the sale. The Liquid block then emits priceValidUntil only when the metafield is set, avoiding stale or incorrect dates.

What NOT to do: emit a hard-coded priceValidUntil one year in the future on every product. Google's validator accepts the markup but the field loses its meaning, and on the next site audit the dates are wrong everywhere. Either tie priceValidUntil to a real campaign deadline or omit it.

JSON-LD example — Offer with all the fields

The block below is the Offer sub-object inside a complementary Product block on a Shopify PDP. It uses Liquid variables for everything dynamic and includes priceValidUntil only when the sale-end metafield is present.

JSON-LD Offer sub-object with priceValidUntil branch, availability map, and seller reference
 "offers": { "@type": "Offer", "url": "{{ shop.url }}{{ product.url }}", "priceCurrency": "{{ shop.currency }}", "price": "{{ product.price | divided_by: 100.0 }}", {%- if product.metafields.custom.sale_ends_at -%} "priceValidUntil": "{{ product.metafields.custom.sale_ends_at | date: '%Y-%m-%d' }}", {%- endif -%} "availability": "{%- if product.available -%}https://schema.org/InStock{%- else -%}https://schema.org/OutOfStock{%- endif -%}", "itemCondition": "https://schema.org/NewCondition", "sku": "{{ product.selected_or_first_available_variant.sku }}", "seller": { "@type": "Organization", "@id": "{{ shop.url }}#organization", "name": "{{ shop.name | escape }}" } } 

Validation

Run the live PDP through Google's Rich Results Test. Expected: Product detected, the embedded Offer parsed, zero errors. Common warnings: 'Either offers.priceValidUntil or offers.itemCondition is recommended' (clears when you add either), 'Missing field hasMerchantReturnPolicy' (clears with the dedicated MerchantReturnPolicy block), 'Missing field shippingDetails' (clears with the dedicated OfferShippingDetails block). All three are warnings, not errors — they affect Merchant Center eligibility but not basic rich snippet eligibility.

If the Rich Results Test reports the Offer has price '0.00' or a wildly wrong number, the cause is almost always Liquid: either product.price was not divided by 100.0, or you used {{ product.price }} directly instead of going through | divided_by: 100.0. The fix is one filter chain.

Shopify gotchas on Offer

Three gotchas catch most Shopify Offer schema work. First: emitting price in cents (12500) instead of dollars (125.00) — Shopify stores prices in cents, the divided_by filter is mandatory. Second: hard-coding priceCurrency instead of using shop.currency, which breaks immediately on stores that switch base currency. Third: hard-coding priceValidUntil to a far-future date on every product, which validates but is meaningless — branch on a real metafield.

A fourth gotcha for variant pricing: product.price on a multi-variant product returns the default-variant price, not necessarily the lowest. If your storefront advertises 'from $X' pricing, use product.price_min | divided_by: 100.0 and consider an offers AggregateOffer with lowPrice and highPrice instead of a single Offer.