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. Their PDP variant dropdown was missing variants. Not all of them. The first 250 were there. The next 1,400 were just gone. The Shopify Admin showed every variant as active, in stock, and correctly priced. The theme template looped product.variants exactly the way Dawn does it. Nothing was throwing. Nothing in the network tab looked wrong.
The bug was the 250-variant Liquid cap. It is not a deprecation, it is not a bug in their theme, and it is not new. Shopify added it years ago to keep render performance sane on the millions of stores that have under 250 variants. What is new is that Shopify lifted the underlying per-product ceiling to 2,048 in 2025, so for the first time merchants can have products that genuinely exceed the Liquid limit. The platform docs mention the cap in passing on a single page about high-variant products. Almost no theme audits are catching it. This post is the field reference.
For broader Liquid context, the Shopify Liquid development guide on this site covers the language fundamentals. If your concern is iteration cost in general, the Liquid loop optimization guide is the companion piece. This post is specifically about what happens when the data set itself is truncated before your loop ever runs.
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. Materializing 2,048 variant objects into Liquid scope on every product page hit would balloon time-to-first-byte and put rendering under Shopify’s request timeout for slower products. The 250-variant ceiling is a render-budget guardrail, not a data limit.
To understand why this matters, look at what Liquid has to do for each variant. For every entry in product.variants Shopify hydrates the variant ID, SKU, barcode, price, compare-at price, inventory state, weight, weight unit, all option values, the metafield namespace map, the variant image, the inventory item ID, and the fulfillment service. Even with aggressive caching, that is hundreds of object allocations per variant. Multiply by 2,048 and you are at six figures of allocations per page render before your template runs a single tag.
Shopify’s own internal benchmarks for high-variant products on Dawn-style themes were the basis for the cap. The 250 number is conservative on purpose, sized to keep a P99 render under the request budget on a cold cache. It is the same reason collection.products paginates at 50 by default and paginate caps at 250: anywhere Liquid materializes a large array, there is a ceiling.
The Admin API and the Storefront API do not have the same constraint because they paginate explicitly. You ask for the first 100 variants, you get a cursor, you ask for the next 100. The render path cannot do that without changing the language semantics of product.variants, so Shopify chose to silently truncate instead. That choice is debatable. It is also the reality every theme dev has to design around.
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 for two minutes, 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.
You can also detect from the browser. Open a known high-variant product, open DevTools, and run the following in the console:
fetch(window.location.pathname + '.json')
.then(r => r.json())
.then(p => {
console.log('admin variants:', p.product.variants.length);
console.log('liquid sees:', document.querySelectorAll('[data-variant-id]').length);
});
The .json endpoint exposes the true variant count from Shopify’s storefront proxy. Compare it to whatever your theme rendered into the DOM. If the DOM is at 250 and the JSON is at 1,847, you are hitting the cap.
For the case study angle, the Enea Studio luxury jewelry build is a small-scale variant example: metal swap pairs across rings and pendants, deliberately kept under 100 variants per product so the Liquid render path stays trivial. The lesson there generalizes upward. As long as you stay well under 250 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:
- 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 inaccessible from the storefront.
- Swatch grid shows partial color set. Automotive parts store with 60 vehicle years by 35 fitments per year hits 2,100 variants. Years 2010 through 2024 render fine. 2025 onward is missing because those variant IDs are higher.
- Price range badge is wrong. B2B catalog with tiered pricing per quantity break loops
product.variantsto find min and max price. Min is correct. Max reflects only the first 250 variants, so the “From {{ price | money }} to {{ price | money }}” line undersells the catalog. - Inventory total is wrong. Theme computes
{% assign total = 0 %}{% for v in product.variants %}{% assign total = total | plus: v.inventory_quantity %}{% endfor %}and reports a total that ignores stock past variant 250. - App integrations partially break. Apps that inject script tags reading
window.product.variantsget whatever Shopify serialized into the JSON-LD or product JSON. If the theme used Liquid to build that JSON, the app sees 250 variants. If the theme used the Storefront API, the app sees all of them. Inconsistent app behavior across PDPs is a strong signal you have a Liquid-source-of-truth problem.
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: the merchant tells you a customer cannot order a variant, you check Admin, the variant is there with stock, and the theme code reads correct.
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. Audit, instrument, patch with Fix 3, plan Fix 1, ship Fix 1, and remove the patch. Each step is independently shippable and rollback-safe. Total wall-clock for a working PDP rebuild on a single product template is typically one to two weeks.
The checklist:
- Audit. Run the detection snippet across every product. Build a list of products where
variants.size != variants_count. For each, note the gap. A 251-vs-260 gap is a different urgency than 250-vs-1,800. - Instrument. Add a non-blocking warning log on the product template so any new products that cross 250 raise a console warning. Pipe to your error tracker if you have one.
- Patch with Fix 3. Ship the JSON endpoint hydration on the affected templates first. This stops the bleeding in hours, not days.
- Validate. Add cart tests across the variant tail. Add at least one variant from index 251, 500, 1,000, and the highest index to your QA cart suite.
- Plan Fix 1. Provision a Storefront API token, set up the GraphQL pagination loop, write the hydration code, and put it behind a theme setting toggle.
- Ship Fix 1. Roll out to a duplicate theme, QA on the high-variant products, then publish.
- Remove the patch. Delete the Fix 3 hydration once Fix 1 is confirmed working in production.
- Document. Write up the variant cap, the chosen fix, and the runbook for adding new high-variant products. Future devs will hit this and your future self will thank you.
The migration is also a good time to revisit whether you actually need 2,048 variants. Many catalogs that spill past 250 are doing so because option groups encode data that should live in metafields or in linked products. A vehicle fitment table with 600 entries per product is a metaobject join. A B2B price tier with 40 quantity breaks is a quantity rules table. The Shopify product selector for vehicle fitment post on this site walks through that re-architecture in depth.
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
.jsendpoint) 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.