Shopify's Hidden 250-Variant Liquid Cap (And the Fix)

TL;DR: Shopify lifted the per-product variant ceiling to 2,048 in late 2025, but the Liquid object product.variants still caps at 250 entries at render time. Any theme iterating product.variants to build a selector, compute price ranges, or aggregate inventory silently misses every variant past index 250. Three production fixes: paginated Storefront API fetch from the browser, a metaobject-backed variant index rendered server-side, or a hybrid Liquid + JSON endpoint.

A merchant pinged me last month with a confusing bug: the PDP variant dropdown was missing variants. The first 250 were there. The next 1,400 were gone. Shopify Admin showed every variant active and in stock. The theme looped product.variants exactly the way Dawn does it. Nothing was throwing.

The bug was the 250-variant Liquid cap. Shopify added it years ago to keep render performance sane on the millions of stores with under 250 variants. What is new is that Shopify lifted the per-product ceiling to 2,048 in 2025, so for the first time merchants can have products that exceed the Liquid limit. Almost no theme audits catch it. This post is the field reference. For broader Liquid context see the Liquid development guide and the loop optimization guide.

What is the 250-variant Liquid cap?

The 250-variant Liquid cap is a Shopify-imposed ceiling on the product.variants array inside the Liquid render path. Even when the underlying product has up to 2,048 variants in the database, accessing product.variants from a theme template returns at most the first 250 variants by ID order. Other Liquid objects like product.variants_count, product.selected_or_first_available_variant, and the product_option_value object are not capped and reflect the true product data.

The cap is documented under the official Support high-variant products reference. Shopify states plainly that product.variants is restricted to a maximum of 250 entries to prevent poor render performance in themes. There is no toggle, no theme setting, and no Plus override. It applies to every theme on every plan.

The cap is render-time, not storage-time. Your product still has all 2,048 variants. The Admin API, the Storefront API, the Cart API, and the checkout all see the full set. The truncation happens only when Liquid materializes the variants array for template execution. This is why the bug presents as a phantom variant problem: backend data is intact, but the storefront UI is missing rows.

The cutoff is consistent. The first 250 variants by Shopify variant ID are the ones that survive. There is no slicing, no random sample, and no priority order based on inventory or position. If your product has 1,000 variants and you need variant number 999 to show up, it will not, because variants 251 through 1,000 are not in the array Liquid hands you.

Why does product.variants only return 250 even though the product has 2,048?

Liquid is a synchronous, server-rendered template language with strict per-request CPU and memory budgets. For every entry in product.variants, Shopify hydrates the variant ID, SKU, barcode, prices, inventory state, weight, all option values, metafields, image, and fulfillment service. That is hundreds of object allocations per variant. At 2,048 variants you hit six figures of allocations per page render before your template runs a single tag. The 250-variant ceiling is a render-budget guardrail, not a data limit.

The same pattern shows up elsewhere in Liquid: collection.products paginates at 50 by default, paginate caps at 250. Anywhere Liquid materializes a large array, there is a ceiling. The Admin API and Storefront API do not have the constraint because they paginate explicitly. The Liquid render path cannot do that without changing the language semantics of product.variants, so Shopify chose to silently truncate.

How to detect if your store is hitting the cap

Add {{ product.variants.size }} and {{ product.variants_count }} to a debug snippet on the product template. If the two numbers diverge on any product, that product is over the 250 cap and the theme is rendering an incomplete variant set. The check is one line and runs on every product page load.

The detection snippet I drop into client audits looks like this:

{% comment %} debug-variant-cap.liquid {% endcomment %}
{%- if product.variants.size != product.variants_count -%}
  <script>
    console.warn(
      "[variant-cap] product {{ product.id }} has {{ product.variants_count }} variants but Liquid sees {{ product.variants.size }}. Cap hit."
    );
  </script>
{%- endif -%}

Render that snippet from main-product.liquid, run a crawl across /products/*, and grep your console output. Every product that logs the warning is one where the variant selector, the price range badge, and any in-Liquid inventory math are running on partial data.

As long as you stay well under 250 variants you can iterate product.variants freely. Past 250 the architecture has to change.

What breaks first when you cross 250 variants

The variant selector breaks first. Themes that render swatches, dropdowns, or option pickers by iterating product.variants will silently drop every option combination tied to a variant past index 250. Customers cannot select those variants and therefore cannot add them to cart, even though Shopify lists them as in stock. Price ranges, sale badges, lowest-price logic, and Liquid-driven inventory aggregations all miss the tail in the same way.

Concrete failure modes I have seen on real client stores:

  1. Variant dropdown stops at the 250th variant. Apparel store with 14 sizes by 38 colors by 4 fits hits 2,128 variants. Customers see 250 in the dropdown; the other 1,878 are unreachable from the storefront.
  2. Price range badge is wrong. B2B catalog with tiered pricing per quantity break loops product.variants to find min and max. Max reflects only the first 250, so the “From X to Y” line undersells the catalog.
  3. App integrations partially break. Apps that read window.product.variants get whatever Shopify serialized. If the theme built that JSON from Liquid, the app sees 250 variants. If the theme used the Storefront API, the app sees all of them. Inconsistent app behavior across PDPs is the tell.

The Admin order flow keeps working because that uses the Admin API. Drafts, manual orders, POS, and B2B price lists all see the full variant set. The bug is storefront-only, which is exactly what makes it hard to diagnose.

Fix 1: Paginated Storefront API call from theme JS

For high-variant products, fetch the full variant set from the browser using the Storefront API GraphQL endpoint. Liquid renders a thin shell with the first 250 variants for SEO and initial paint, then JavaScript paginates the remaining variants and hydrates the selector. This is the pattern Shopify recommends for catalog-heavy stores and the one I use most often in production.

The Liquid shell stays minimal. It only needs the product handle and a placeholder selector:

<form action="/cart/add" method="post" data-product-form="{{ product.handle }}">
  <select name="id" data-variant-select>
    {%- comment -%} initial 250 from Liquid for no-JS fallback {%- endcomment -%}
    {%- for v in product.variants -%}
      <option value="{{ v.id }}" {%- if v.available == false %} disabled{%- endif -%}>
        {{ v.title }} - {{ v.price | money }}
      </option>
    {%- endfor -%}
  </select>
  <button type="submit">Add to cart</button>
</form>

<script>
  window.__variantBootstrap = {
    handle: {{ product.handle | json }},
    productId: {{ product.id }},
    expectedCount: {{ product.variants_count }},
    liquidCount: {{ product.variants.size }}
  };
</script>

The GraphQL query lives in assets/variant-loader.js. The Storefront API caps each page at 250, so you paginate with cursors:

query VariantsByHandle($handle: String!, $cursor: String) {
  product(handle: $handle) {
    variants(first: 250, after: $cursor) {
      edges {
        cursor
        node {
          id
          title
          availableForSale
          price { amount currencyCode }
          selectedOptions { name value }
        }
      }
      pageInfo { hasNextPage endCursor }
    }
  }
}

The fetch loop:

async function loadAllVariants(handle) {
  const endpoint = `/api/2025-10/graphql.json`;
  const query = `
    query VariantsByHandle($handle: String!, $cursor: String) {
      product(handle: $handle) {
        variants(first: 250, after: $cursor) {
          edges {
            cursor
            node {
              id
              title
              availableForSale
              price { amount currencyCode }
              selectedOptions { name value }
            }
          }
          pageInfo { hasNextPage endCursor }
        }
      }
    }
  `;

  const all = [];
  let cursor = null;

  while (true) {
    const res = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Storefront-Access-Token': window.__storefrontToken
      },
      body: JSON.stringify({ query, variables: { handle, cursor } })
    });

    const json = await res.json();
    const conn = json.data.product.variants;
    all.push(...conn.edges.map(e => e.node));

    if (!conn.pageInfo.hasNextPage) break;
    cursor = conn.pageInfo.endCursor;
  }

  return all;
}

document.addEventListener('DOMContentLoaded', async () => {
  const ctx = window.__variantBootstrap;
  if (ctx.expectedCount <= ctx.liquidCount) return;

  const variants = await loadAllVariants(ctx.handle);
  hydrateSelector(variants);
});

hydrateSelector rebuilds the <select> with the full set. SEO and the no-JS fallback still work because Liquid rendered 250 valid options on the initial paint. Customers with JavaScript enabled see all 2,048.

The cost is one Storefront API token configured in your theme, roughly 9 paginated requests for a 2,048-variant product, and a hydration step that runs after first paint. If you cache the variant list in localStorage keyed by product handle and updated_at timestamp, the second visit costs zero requests.

Fix 2: Variant index via metaobject (server-rendered)

Encode the full variant index as a metaobject keyed by product, then render the variant selector from the metaobject in Liquid. This pattern keeps the entire selection flow server-side, which is the right call when SEO on variant URLs matters more than client-side reactivity. It is also the most app-free fix.

Define a metaobject variant_index with two fields: product_reference and a JSON field variant_payload that holds an array of { id, title, options, available, price } objects. A nightly cron, an Admin API webhook on products/update, or a manual sync job populates the metaobject from the Admin API where the 2,048 limit is fully addressable.

The Liquid template reads the metaobject:

{%- assign idx = product.metafields.custom.variant_index.value -%}

<select name="id" data-variant-select>
  {%- for v in idx.variant_payload -%}
    <option value="{{ v.id }}" {%- unless v.available %} disabled{%- endunless -%}>
      {{ v.title }} - {{ v.price | money }}
    </option>
  {%- endfor -%}
</select>

Because idx.variant_payload is a JSON array stored in a metafield, it bypasses the product.variants 250 cap entirely. Liquid hydrates whatever JSON you stored, up to the metafield size limit of 100 KB, which fits roughly 1,500 to 2,000 minimal variant records depending on title length.

The sync job is the work. The simplest version is a Shopify Flow workflow triggered on Product update that calls a webhook to a small Cloudflare Worker. The Worker pulls the full variant set from the Admin API, compresses each record to the minimum fields needed by the storefront, and writes the JSON back into the metaobject via metaobjectUpsert. Total round trip is a few seconds per product.

The trade-off is staleness. If you sell out of variant 1,800 between syncs, the Liquid render still says it is available until the next refresh. For B2B catalogs, configurable industrial products, and any vertical where inventory churn is hours not minutes, that is acceptable. For fast-moving DTC fashion, it is not.

This is also the pattern that pairs well with custom Liquid sections and the broader argument I make in replace apps with Liquid snippets: you do not need a third-party variant manager when the platform has metaobjects.

Fix 3: Hybrid Liquid + JSON endpoint pattern

Liquid renders the first 250 variants for the initial selector and SEO. A JSON endpoint at /products/{handle}.js serves the full variant set for client-side hydration. This pattern requires zero apps, zero metaobjects, and zero Storefront API tokens, because Shopify already exposes the JSON endpoint on every theme.

Every product on every Shopify store responds to /products/{handle}.js with a JSON payload that includes the full variant array, not the Liquid 250 truncation. This endpoint is the same one the Ajax Cart API uses internally and it is unaffected by the render-time cap.

The hybrid flow:

<form action="/cart/add" method="post">
  <select name="id" data-variant-select>
    {%- for v in product.variants -%}
      <option value="{{ v.id }}">{{ v.title }} - {{ v.price | money }}</option>
    {%- endfor -%}
  </select>
</form>

<script>
  window.__productHandle = {{ product.handle | json }};
  window.__expectedVariants = {{ product.variants_count }};
  window.__renderedVariants = {{ product.variants.size }};
</script>

The hydration script:

(async function hydrateVariants() {
  if (window.__expectedVariants <= window.__renderedVariants) return;

  const res = await fetch(`/products/${window.__productHandle}.js`);
  const product = await res.json();

  const select = document.querySelector('[data-variant-select]');
  select.innerHTML = '';

  for (const v of product.variants) {
    const opt = document.createElement('option');
    opt.value = v.id;
    opt.disabled = !v.available;
    opt.textContent = `${v.title} - ${formatMoney(v.price)}`;
    select.appendChild(opt);
  }
})();

function formatMoney(cents) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: window.Shopify.currency.active
  }).format(cents / 100);
}

Three things to know about this pattern. First, the .js endpoint returns prices in cents as integers, not as Liquid money-formatted strings, so you format on the client. Second, the endpoint is cached by Shopify’s CDN with a short TTL, so high-traffic PDPs do not hammer the origin. Third, this works on every plan including Basic Shopify, because no Storefront API access is required.

The downside is the JSON endpoint is undocumented as a public API surface. Shopify has not deprecated it in over a decade, but they could in theory. For most production stores I would still recommend Fix 1 (paginated Storefront API) as the long-term play and use Fix 3 as a quick patch when the merchant needs the bug gone today.

Which fix to use when

Fix 1 is the default for any DTC store with real-time inventory needs and a willingness to manage a Storefront API token. Fix 2 is the default for B2B catalogs and configurable industrial products where SEO-friendly variant URLs and server-side rendering outweigh client reactivity. Fix 3 is the right immediate patch when the merchant is bleeding revenue on a hidden variant and needs the bug closed in an afternoon, with a planned migration to Fix 1 or Fix 2 later.

Concern Fix 1: Storefront API Fix 2: Metaobject index Fix 3: JSON endpoint
App-free Yes Yes Yes
Works on Basic Shopify Yes Yes Yes
Real-time inventory accuracy Yes No, sync-bound Yes
SEO-friendly variant URLs Partial Yes Partial
Engineering effort Medium Medium-high Low
Time to ship 1-2 days 3-5 days 2-4 hours
Long-term maintainability High High Medium
Risk of Shopify deprecation Low Low Medium

Pair the choice with a clear default. If you are unsure, ship Fix 3 today, ship Fix 1 next week, and revisit Fix 2 only if the merchant has a strong SEO case for indexed variant URLs.

The migration plan if you are already over 250 variants

The migration is mechanical and reversible. Total wall-clock for a single product template is typically one to two weeks.

  1. Audit. Run the detection snippet across every product. List the gaps; 251-vs-260 is a different urgency than 250-vs-1,800.
  2. Patch with Fix 3. Ship the JSON endpoint hydration on the affected templates first. This stops the bleeding in hours, not days.
  3. Validate. Add variants from index 251, 500, 1,000, and the highest index to your QA cart suite.
  4. Ship Fix 1. Provision a Storefront API token, write the GraphQL pagination loop, roll out to a duplicate theme, QA, then publish.
  5. Remove the patch. Delete the Fix 3 hydration once Fix 1 is confirmed in production.

The migration is also a good time to revisit whether you actually need 2,048 variants. Many catalogs that spill past 250 are encoding data that should live in metafields or linked products. A vehicle fitment table with 600 entries per product is a metaobject join. The Shopify product selector for vehicle fitment post walks through that re-architecture.

The takeaway

  • Detect the cap with {{ product.variants.size }} vs {{ product.variants_count }}. If they diverge on any product, that template is rendering an incomplete variant set.
  • Patch with Fix 3 (the .js endpoint) the same day the bug is reported. 2-4 hours of work and the bleeding stops on every affected PDP.
  • Migrate to Fix 1 (paginated Storefront API) within a week. Provision a Storefront API token, write the GraphQL pagination loop, ship behind a theme setting.
  • Reserve Fix 2 (metaobject variant index) for B2B catalogs and configurable industrial products where SEO-friendly variant URLs and server-side rendering outweigh real-time inventory accuracy.
  • Test the tail. Add variants from index 251, 500, 1,000, and the highest index to your QA cart suite so a future regression cannot ship silently.

Have a Shopify store with 1,000+ variants and PDP issues? Book a free 30-minute audit.

Frequently Asked Questions

Does product.variants really cap at 250 even after Shopify increased the variant limit to 2,048?

Yes. Shopify lifted the per-product variant ceiling to 2,048 in late 2025, but the Liquid object product.variants is restricted at render time to a maximum of 250 entries. The cap exists because Liquid is a synchronous, server-rendered template language and iterating thousands of variants on every product page request would crush time-to-first-byte. The Admin API, the Storefront API, and product.variants_count all return the true count. Only the Liquid array is capped.

How do I detect if my Shopify store is hitting the 250 variant cap?

Compare product.variants.size to product.variants_count in any Liquid template. If they diverge, you are over the cap and your theme is rendering an incomplete variant set. The cleanest test is to drop {{ product.variants.size }} vs {{ product.variants_count }} into a debug snippet on a product template, then load a known high-variant product. If you see 250 vs 1,847, every variant from index 251 onward is invisible to your selector, swatches, price logic, and any inventory math computed in Liquid.

What breaks first when a product crosses 250 variants?

The variant selector. If your theme iterates product.variants to render a dropdown or swatch grid, customers can no longer pick variants 251 through 2,048, so those variants cannot be added to cart from the storefront even though Shopify Admin lists them as active. Inventory aggregations, lowest-price calculations, sale-badge logic, and any apps that read variant data through Liquid all silently miss the tail. The Admin order flow still works, so the bug only appears on the storefront.

Should I switch to product.selected_or_first_available_variant and product_option_value?

Yes for the initial render. Shopify shipped product.selected_or_first_available_variant and the product_option_value object specifically so themes can render a correct PDP without iterating the full variant array. Use variant.available and product_option_value.available for option-level availability instead of looping product.variants. This is the official Shopify path and it works regardless of whether the product has 50 variants or 2,048. It does not solve the case where you genuinely need every variant in the browser, which is where the three fixes in this post come in.

Will the Storefront API return all 2,048 variants in one query?

No. The Storefront API uses cursor-based pagination with a default page size around 100 variants per request and a hard ceiling of 250 per page. To pull every variant on a 2,048-variant product you make roughly 9 to 21 paginated calls and concatenate the results. The benefit is the API runs from the browser, not the Liquid render path, so you do not block first paint. The cost is engineering time to write the pagination loop and cache the result on the client.

Is there a way to keep variant selection working in Liquid without any JavaScript?

Partially. If your variant matrix has predictable structure you can encode the full variant index in a metaobject and render only the option values needed for the current selection in Liquid. The shopper picks options, the page reloads with selected_variant query parameters, and Shopify returns the correct variant on the next render. This is server-side variant selection and it works for catalogs where SEO-friendly variant URLs matter more than instant client-side updates. It is the right pattern for B2B catalogs and configurable industrial products, not for fashion.

Book Strategy Call