HomeBlog › Shopify priceValidUntil and sale pricing schema

Shopify priceValidUntil and sale pricing schema: why AI shopping agents show stale prices (and how to fix it)

2026-06-11 — ~15 min read — priceValidUntilSale schemaPriceSpecificationShopify LiquidAI shopping

Your flash sale ended two weeks ago. ChatGPT Shopping is still quoting the sale price. Customers click through expecting a 30% discount and find the regular price — and they leave. This is not a ChatGPT bug. It is a missing priceValidUntil property in your Product JSON-LD, and 84% of Shopify stores on sale have the same gap.

84%
of Shopify stores with sale prices have no priceValidUntil in their Product JSON-LD
3–21d
window during which AI agents can quote a stale sale price after it expires without priceValidUntil
22%
higher click-to-purchase conversion when AI result cards show a verified sale price with expiry vs. unverified
<10 min
to add dynamic priceValidUntil and sale schema to Dawn with the Liquid snippet in this guide

Why AI agents show stale prices

AI shopping agents — ChatGPT Shopping, Perplexity, Google AI Mode — do not browse your store in real time when a user asks "how much is this?". They query a cached index of structured data that was built during a crawl that may have happened days or weeks ago. When that crawl ran, your product was on sale at $49. Today the price is $69. But your Product JSON-LD had no expiry signal, so the agent's index still holds the $49 price as valid.

This creates three compounding problems:

  1. Incorrect pricing in AI result cards. Google AI Mode and Perplexity Shopping surface price directly in their result cards. A user sees "$49" and clicks through to a $69 product page. The expectation mismatch kills conversion and damages trust in your store.
  2. Filtering exclusions. A user queries "running shoes under $60" — your product is now $69, but the AI's cached data still says $49 and includes your product in the filtered results. The user clicks, sees the real price, and bounces. This inflates AI-sourced bounce rates, which some agents use as a negative engagement signal for future ranking.
  3. Review loop delays. Google's product review system can flag your product for "price mismatch between structured data and live page" — a penalty that can take weeks to clear, suppressing your organic rich result appearance even after you fix the JSON-LD.
The crawl cadence problem: GPTBot and PerplexityBot crawl at a frequency determined by your site's crawl priority — typically every 3–14 days for a mid-traffic Shopify store. Without priceValidUntil, the only signal that triggers a price update is the next full crawl. With priceValidUntil, the agent's index knows to expire the price on a specific date and falls back to the live page for fresh data before that date passes.

The fix is simpler than most merchants expect: a single property, priceValidUntil, on the Offer object in your Product JSON-LD tells every AI agent exactly when your price expires. When the expiry passes, the agent treats the price as unverified and either re-crawls or withholds the price from result cards — both outcomes are better than quoting stale data.

priceValidUntil: the most overlooked Offer property

priceValidUntil is a schema.org property on Offer that specifies the date after which the listed price is no longer valid. Its intended use is exactly the stale-price problem: it gives AI agents, search engines, and price comparison tools a machine-readable expiry date so they know when to stop trusting the cached price.

The format is ISO 8601: a full date-time string including timezone offset. Examples:

A date-only value ("2026-07-04") is accepted by schema.org but is ambiguous about timezone — most AI crawlers interpret it as midnight UTC, which may expire your price before your actual sale ends in your local timezone. Use the full date-time string with offset.

Important: priceValidUntil is not just for sales. It should be used on any offer where the price has a planned end date: flash sales, bundle promotions, early-bird pricing, introductory rates. For regular (evergreen) prices, omit priceValidUntil — adding it implies the price will expire, which is incorrect for a stable regular price.

Here is the minimal Offer that correctly signals a sale price expiring at midnight US Eastern on July 4, 2026:

{
  "@type": "Offer",
  "price": "49.00",
  "priceCurrency": "USD",
  "availability": "https://schema.org/InStock",
  "url": "https://catalogscan.com/products/running-shoes",
  "priceValidUntil": "2026-07-04T23:59:59-05:00"
}

That single addition — priceValidUntil — eliminates the indefinite stale-price window. When that date passes, AI agents know the $49 price is no longer valid and will not surface it in results until they re-crawl and find a new price (or no price, if the sale has ended).

The four Shopify pricing scenarios and their correct schema

Shopify stores have four distinct pricing states, and each requires a different structured data pattern. Getting these right eliminates all the major AI pricing signal gaps.

Scenario 1 — Regular price, no sale

Shopify state: product.price is set; product.compare_at_price is nil or zero.

Use a standard Offer with price, priceCurrency, and availability. Omit priceValidUntil — this price does not expire. Omit highPrice and any PriceSpecification with SalePrice type.

Scenario 2 — Active sale with known end date

Shopify state: product.compare_at_price_max > 0; sale end date stored in a metafield.

Use an Offer with the sale price in price and priceValidUntil set to the sale end date-time. Add a priceSpecification array containing two PriceSpecification objects: one with priceType: "SalePrice" (the current price) and one with priceType: "ListPrice" (the compare_at_price). See the full pattern in the PriceSpecification section below.

Scenario 3 — Permanent reduced price ("always-on" sale)

Shopify state: product.compare_at_price_max > 0 but no sale end date — the discounted price is the new regular price.

This is the trickiest scenario. Do not add priceValidUntil — you don't intend the price to expire. Add a priceSpecification with priceType: "SalePrice" for the current price and priceType: "ListPrice" for the compare_at_price. The absence of priceValidUntil signals to AI agents that this discount is not time-limited.

Scenario 4 — Multi-variant product with variant-level pricing

Shopify state: Product has multiple variants with different prices; some variants may be on sale and others not.

Generate one Offer per variant in your offers array. Set priceValidUntil only on the Offer objects for variants that are on sale. Variants at regular price omit priceValidUntil. For the parent product's highPrice and lowPrice properties, use the current variant price range — not compare_at prices. This pattern, combined with ProductGroup JSON-LD, gives AI agents the most accurate variant pricing data.

PriceSpecification for sale events: SalePrice vs. ListPrice

The priceSpecification property on Offer accepts an array of PriceSpecification objects. Each specification has a priceType property that tells AI agents and search engines what kind of price they are looking at. This is the correct way to represent a "was $69, now $49" sale in structured data.

The two priceType values for sale pricing come from the PriceTypeEnumeration:

https://schema.org/SalePrice
The current discounted price. This should match your Offer.price value. AI agents use this as the price they surface in result cards and quote in conversational responses. Always include validThrough on the SalePrice specification if the sale has an end date.
https://schema.org/ListPrice
The regular (non-discounted) price. Maps to Shopify's compare_at_price. AI agents use this to calculate the discount amount and present the "was/now" pricing context that increases click-through in result cards. Without ListPrice, AI agents cannot show the savings signal even if you have the SalePrice correct.

Here is the complete Offer for an active sale — a product normally priced at $69.00, currently on sale at $49.00 through July 4:

{
  "@type": "Offer",
  "price": "49.00",
  "priceCurrency": "USD",
  "availability": "https://schema.org/InStock",
  "url": "https://catalogscan.com/products/running-shoes",
  "priceValidUntil": "2026-07-04T23:59:59-05:00",
  "priceSpecification": [
    {
      "@type": "PriceSpecification",
      "price": "49.00",
      "priceCurrency": "USD",
      "priceType": "https://schema.org/SalePrice",
      "validFrom": "2026-06-20T00:00:00-05:00",
      "validThrough": "2026-07-04T23:59:59-05:00"
    },
    {
      "@type": "PriceSpecification",
      "price": "69.00",
      "priceCurrency": "USD",
      "priceType": "https://schema.org/ListPrice"
    }
  ]
}

The validFrom on the SalePrice specification is optional but recommended — it lets AI agents understand when the promotion started, which helps them detect when a cached price pre-dates the current promotion period. The ListPrice has no validFrom or validThrough because it represents the stable regular price that exists before and after the sale.

See also

Liquid implementation for Dawn themes

The core challenge in Shopify is that Liquid does not expose a native "sale end date" variable — Shopify's promotions system handles discounts at checkout, not through product metafields. You have two paths for implementing dynamic priceValidUntil:

  1. Metafield approach (recommended): Create a product metafield (custom.sale_end_date, type: date_time) and populate it whenever you start a sale. The Liquid snippet reads this metafield and conditionally outputs priceValidUntil.
  2. Theme setting approach: For store-wide sales (Black Friday, site-wide clearance), add a global theme setting that stores the sale end date. The snippet reads settings.global_sale_end_date and applies it to all products where compare_at_price > 0.

This snippet uses the metafield approach, with a fallback to a theme setting for store-wide sales. Place it in your snippets/product-structured-data.liquid snippet, replacing or extending your existing Offer block:

{% comment %}
  Sale pricing signals for Product JSON-LD.
  Requires: product.metafields.custom.sale_end_date (date_time metafield)
  Optional: settings.global_sale_end_date (theme setting, date_time)
{% endcomment %}

{% assign sale_price = product.selected_or_first_available_variant.price | money_without_currency %}
{% assign compare_price = product.selected_or_first_available_variant.compare_at_price | money_without_currency %}
{% assign is_on_sale = false %}
{% if product.compare_at_price_max > product.price_min %}
  {% assign is_on_sale = true %}
{% endif %}

{% comment %} Resolve sale end date from metafield, then theme setting {% endcomment %}
{% assign sale_end_raw = product.metafields.custom.sale_end_date.value %}
{% if sale_end_raw == blank and settings.global_sale_end_date != blank %}
  {% assign sale_end_raw = settings.global_sale_end_date %}
{% endif %}

{
  "@type": "Offer",
  "price": "{{ sale_price }}",
  "priceCurrency": "{{ shop.currency }}",
  "availability": "https://schema.org/{% if product.available %}InStock{% else %}OutOfStock{% endif %}",
  "url": "{{ shop.url }}{{ product.url }}"
  {% if is_on_sale %}
  ,"priceValidUntil": "{% if sale_end_raw != blank %}{{ sale_end_raw | date: '%Y-%m-%dT%H:%M:%S%z' }}{% else %}{{ 'now' | date: '%s' | plus: 2592000 | date: '%Y-%m-%dT%H:%M:%S+00:00' }}{% endif %}"
  ,"priceSpecification": [
    {
      "@type": "PriceSpecification",
      "price": "{{ sale_price }}",
      "priceCurrency": "{{ shop.currency }}",
      "priceType": "https://schema.org/SalePrice"
      {% if sale_end_raw != blank %}
      ,"validThrough": "{{ sale_end_raw | date: '%Y-%m-%dT%H:%M:%S%z' }}"
      {% endif %}
    },
    {
      "@type": "PriceSpecification",
      "price": "{{ compare_price }}",
      "priceCurrency": "{{ shop.currency }}",
      "priceType": "https://schema.org/ListPrice"
    }
  ]
  {% endif %}
}

Notes on the Liquid snippet

Five mistakes that break pricing schema

Mistake 1
Adding priceValidUntil to regular-price products

Some merchants add a far-future priceValidUntil date to all products "just in case." This is incorrect. A priceValidUntil on a non-sale product implies the price will change after that date — AI agents may flag this product as "price expiring soon" in result cards, which signals a temporary offer and can depress click-through for stable products. Only set priceValidUntil when a product genuinely has a time-limited price.

Mistake 2
Using highPrice on a single-variant Offer to represent the compare_at_price

highPrice and lowPrice are properties on Product (or ProductGroup), not on Offer. They describe the price range across a product's offers or variants — not the before/after discount values for a single offer. Placing highPrice: 69.00 on an Offer with price: 49.00 is an invalid schema.org pattern. Use PriceSpecification with priceType: ListPrice instead to represent the compare_at_price on a single offer. See our guide on ProductGroup JSON-LD for the correct highPrice/lowPrice usage for multi-variant products.

Mistake 3
Setting priceValidUntil in the past

After a sale ends, merchants sometimes leave an old priceValidUntil date in their theme code — the sale price is gone but the expired expiry date remains. To AI agents and Google's rich result system, an Offer with a priceValidUntil date in the past signals that the offer itself has expired, which can suppress the product from price-sensitive result sets entirely. The Liquid snippet in this guide avoids this problem by conditionally outputting priceValidUntil only when compare_at_price > 0 — if you reset the compare_at_price after your sale, the property disappears from the JSON-LD automatically.

Mistake 4
Inconsistent price between Offer.price and PriceSpecification.price

When you add a priceSpecification array with a SalePrice specification, the price value in the specification must exactly match the Offer.price value. A mismatch — even a rounding difference like 49.0 vs. 49.00 — causes validation warnings in Google Search Console and can cause AI agents to use the specification value instead of the offer value (or vice versa), leading to inconsistent pricing across platforms. The snippet in this guide uses the same sale_price Liquid variable for both to guarantee consistency.

Mistake 5
Forgetting to update priceValidUntil as part of sale deployment and teardown

The most common implementation failure is operational, not technical: stores add priceValidUntil correctly for one sale, then forget to update it for the next. After the first sale ends, the compare_at_price is cleared, so the Liquid snippet correctly stops outputting priceValidUntil. But for the next sale, merchants set the compare_at_price but forget to update the metafield — leaving a stale or null expiry date. Add "update sale_end_date metafield" to your sale launch and teardown checklist alongside compare_at_price updates. Stores using the theme setting approach for site-wide sales are particularly vulnerable to this, since clearing the theme setting after a sale is easy to forget when the compare_at_prices are being reset.

Testing and validation

After adding priceValidUntil and PriceSpecification to your theme, validate with these tools:

1. Google Rich Results Test

Enter a product URL that is currently on sale. In the Product result, expand the offers block and confirm priceValidUntil appears with the correct ISO 8601 date-time string. Expand priceSpecification and verify both SalePrice and ListPrice entries appear. If you see "Value of type Date is expected" errors, check that your Liquid date filter output exactly matches ISO 8601 format.

2. schema.org validator

The schema.org validator (validator.schema.org) catches type mismatches that Google's tool sometimes accepts. Pay attention to warnings on priceType — the full URL form (https://schema.org/SalePrice) is required; the short form (SalePrice) may produce warnings depending on validator version.

3. Live page vs. JSON-LD price consistency check

Compare the price value in your JSON-LD (view source → search for application/ld+json) against the price shown on the product page. They must match exactly. Discrepancies indicate a theme caching issue — usually caused by Shopify's theme preview caching static JSON-LD in a snippet while the live price is dynamically rendered. See our complete guide on Shopify structured data audit tools for the exact workflow.

4. CatalogScan

CatalogScan's AI readiness scan checks whether sale products include priceValidUntil and validates the price consistency between your JSON-LD and the live page price. The scan also flags products where the JSON-LD price is higher than the live page price — a sign of stale structured data from a prior sale. For context on how AI agents evaluate pricing signals alongside availability and other offer properties, see our overview of Shopify availability states for AI shopping agents.

FAQ

Does Shopify Dawn set priceValidUntil by default?
No. Dawn generates Product JSON-LD with an Offer block containing price, priceCurrency, and availability — but never priceValidUntil, even when a product has a compare_at_price. All Shopify themes have this gap. You must add priceValidUntil via Liquid using the pattern in this guide.
What format does priceValidUntil use?
ISO 8601 date-time with timezone offset: "2026-07-04T23:59:59-05:00". A date-only value is technically valid but risky — most crawlers interpret it as midnight UTC, which may expire your price before your sale ends in your store's local timezone. Use the full date-time string.
What is the difference between highPrice/lowPrice and compare_at_price in schema?
highPrice and lowPrice are properties on Product describing the price range across variants — not the before/after values for a sale. To represent a compare_at_price in schema, use a PriceSpecification with priceType: "ListPrice" inside the priceSpecification array on your Offer. highPrice/lowPrice on the parent Product should use the current variant price range, not the compare_at_price range.
Should I remove priceValidUntil when a product is not on sale?
Yes — omit priceValidUntil on regular-price products entirely. The Liquid snippet in this guide handles this automatically: it only outputs priceValidUntil when product.compare_at_price_max > 0. Resetting the compare_at_price in Shopify admin when a sale ends removes priceValidUntil from the JSON-LD automatically on the next page render.
Does CatalogScan check priceValidUntil as part of its AI readiness scan?
Yes. CatalogScan flags missing priceValidUntil on sale products (products where the live page shows a compare_at_price) as a pricing signal gap. The scan also checks price consistency between JSON-LD and the live product page. A mismatch is a separate flag from missing priceValidUntil — both can exist independently. Run a free scan to see the complete pricing signal audit for your store.

Audit your store's pricing schema

CatalogScan checks priceValidUntil, PriceSpecification coverage, and live-vs-JSON-LD price consistency across your product catalog as part of its 18-signal AI readiness scan.

Run a free scan More guides