Skip to main content

Avoid deeply nested Liquid loops

Flatten nested loop structures using Liquid filters instead of iterating over data inside another loop.


Nested loops have multiplicative cost. Each iteration of the outer loop runs the full inner loop, so the total number of operations is outer iterations × inner iterations. With 10 products and 10 variants each, that's 100 operations. With 50 products and 10 variants, it's 500, and each of those iterations might access additional Liquid objects, compounding the cost further.

This is O(n²) complexity: doubling the collection size roughly quadruples the rendering work. The cost grows faster than the content does, which is why a theme that performs well on a small catalog can become slow on a larger one.


The fix is to flatten the loop structure by pre-computing what you need before the outer loop starts.

If the inner loop looks for the first item that meets a condition, then replace it with a where filter assigned before the loop:

{%- comment -%} Less performant: an interpreted loop and condition for every product scanned {%- endcomment -%}
{% for collection in collections %}
{% for product in collection.products %}
{% if product.available %}
{{ product.featured_image | image_url: width: 400 | image_tag }}
{% break %}
{% endif %}
{% endfor %}
{% endfor %}

{%- comment -%} More performant: one filter call per collection does the same scan {%- endcomment -%}
{% for collection in collections %}
{% assign in_stock = collection.products | where: "available", true | first %}
{% if in_stock %}
{{ in_stock.featured_image | image_url: width: 400 | image_tag }}
{% endif %}
{% endfor %}

Both versions look at the same products. The difference is where the work happens: the for loop evaluates a tag, a condition, and a loop iteration for every product it touches, while where performs the comparison in a single filter call. That makes the rewrite worth it when matches are rare or absent, because then the loop runs the full inner iteration. If the first item almost always matches, then the loop breaks on its first pass and the two are close, so reach for where when you can't rely on an early match.

If the inner loop checks membership in another array, then use map to extract the values you need before the outer loop starts, then use contains for a direct lookup:

{%- comment -%} Less performant: inner loop re-scans all cart items on every variant iteration {%- endcomment -%}
{% for variant in product.variants %}
{% assign in_cart = false %}
{% for item in cart.items %}
{% if item.variant_id == variant.id %}
{% assign in_cart = true %}
{% break %}
{% endif %}
{% endfor %}
{% if in_cart %}<span>In cart</span>{% endif %}
{% endfor %}

{%- comment -%} More performant: build cart ID lookup once, then check contains {%- endcomment -%}
{% assign cart_variant_ids = cart.items | map: "variant_id" %}
{% for variant in product.variants %}
{% if cart_variant_ids contains variant.id %}
<span>In cart</span>
{% endif %}
{% endfor %}

Anchor to Use ,[object Object], instead of a loop to output list valuesUse join instead of a loop to output list values

If the inner loop just concatenates values with a separator, then join does the same work without a nested iteration:

{%- comment -%} Less performant: inner loop over variant.options on every variant iteration {%- endcomment -%}
{% for variant in product.variants %}
<p>
{% for option in variant.options %}
{{ option }}{% unless forloop.last %} / {% endunless %}
{% endfor %}
</p>
{% endfor %}

{%- comment -%} More performant: join outputs the same result without an inner loop {%- endcomment -%}
{% for variant in product.variants %}
<p>{{ variant.options | join: " / " }}</p>
{% endfor %}

Anchor to Flatten a collection list sectionFlatten a collection list section

A collection list section that shows a preview image, an in-cart badge, and an option summary for each collection can end up with three nested loops inside one outer loop. Precompute each lookup before the outer loop, and the section renders with one pass per collection:

Before

sections/collection-list.liquid

{% for collection in collections %}
<li>
{% for product in collection.products %}
{% if product.available %}
{{ product.featured_image | image_url: width: 400 | image_tag }}
{% break %}
{% endif %}
{% endfor %}

{% assign in_cart = false %}
{% for product in collection.products %}
{% for item in cart.items %}
{% if item.product_id == product.id %}
{% assign in_cart = true %}
{% endif %}
{% endfor %}
{% endfor %}
{% if in_cart %}<span>In your cart</span>{% endif %}

<p>
{% for tag in collection.all_tags %}
{{ tag }}{% unless forloop.last %}, {% endunless %}
{% endfor %}
</p>
</li>
{% endfor %}

The cart lookup is the expensive one, because it's the only loop that can't exit early: it compares every product against every cart line. Build the cart's product IDs once, above the outer loop, and it becomes a single contains check:

After

sections/collection-list.liquid

{% assign cart_product_ids = cart.items | map: "product_id" %}

{% for collection in collections %}
{% assign in_stock = collection.products | where: "available", true | first %}
{% assign collection_product_ids = collection.products | map: "id" %}

<li>
{% if in_stock %}
{{ in_stock.featured_image | image_url: width: 400 | image_tag }}
{% endif %}

{% assign in_cart = false %}
{% for product_id in cart_product_ids %}
{% if collection_product_ids contains product_id %}
{% assign in_cart = true %}
{% break %}
{% endif %}
{% endfor %}
{% if in_cart %}<span>In your cart</span>{% endif %}

<p>{{ collection.all_tags | join: ", " }}</p>
</li>
{% endfor %}

The remaining loop iterates over cart lines, which is a short list bounded by what the customer added, rather than over the catalog. Note that cart_product_ids is assigned once for the whole section, not once per collection, because the cart doesn't change while the section renders.

Anchor to Precompute a lookup that several blocks sharePrecompute a lookup that several blocks share

When more than one block needs the same derived list, assign it once at the top of the section and let each block read it. This keeps the cost fixed no matter how many blocks the merchant adds:

Shared lookup

sections/product.liquid

{% assign cart_variant_ids = cart.items | map: "variant_id" %}
{% assign available_variants = product.variants | where: "available", true %}

{% for block in section.blocks %}
{% case block.type %}
{% when 'variant_picker' %}
{% for variant in available_variants %}
<label>
{{ variant.title }}
{% if cart_variant_ids contains variant.id %}<span>In cart</span>{% endif %}
</label>
{% endfor %}
{% when 'stock_note' %}
<p>{{ available_variants.size }} of {{ product.variants.size }} options in stock.</p>
{% endcase %}
{% endfor %}

Assigning available_variants inside each block would repeat the same filter for every block that needs it. Hoisting it above the block loop runs it once per render.


  • Use the Theme Inspector Chrome extension to visualize Liquid rendering performance.
  • Check the flame graph visualization to see the timeline of rendering.
  • Use the sandwich view to aggregate execution times and find repetitive operations.
  • Sort by Self time to identify expensive operations called frequently.
  • Focus on loops iterating many times with complex operations inside.
  • Compare TTFB in the Chrome DevTools Network panel before and after optimizations.


Was this page helpful?