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:
- 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.
- Price range badge is wrong. B2B catalog with tiered pricing per quantity break loops
product.variantsto find min and max. Max reflects only the first 250, so the “From X to Y” line undersells the catalog. - App integrations partially break. Apps that read
window.product.variantsget 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.
- Audit. Run the detection snippet across every product. List the gaps; 251-vs-260 is a different urgency than 250-vs-1,800.
- Patch with Fix 3. Ship the JSON endpoint hydration on the affected templates first. This stops the bleeding in hours, not days.
- Validate. Add variants from index 251, 500, 1,000, and the highest index to your QA cart suite.
- Ship Fix 1. Provision a Storefront API token, write the GraphQL pagination loop, roll out to a duplicate theme, QA, then publish.
- 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
.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.