Skip to main content

Move metafield access outside loops

Move metafield access outside loops, avoid variant-level metafield checks, limit metaobject loops, and use theme settings instead of metafields when data doesn't change per product.


The most common performance issue with metafields is accessing them inside nested loops. Each metafield access queries the backend, multiplied by variants per product, multiplied by products in collection. For 50 products × 10 variants, that's 500 metafield lookups minimum, which can add seconds to TTFB.

Metaobject loops are particularly expensive: Looping over metaobject entries adds significant overhead, and each entry access queries the backend. An unpaginated loop yields at most 50 entries per page; wrapping it in paginate lets you walk further, which multiplies the backend work rather than removing it. Use metaobject loops only when the page needs them.

The alternative approach is to move metafield access outside loops when possible. For data that doesn't change per product, use theme settings instead of metafields. For complex metafield queries or filtering by metafield values, use the GraphQL Admin API instead of Liquid loops. This is beyond theme scope, but worth knowing for app development.


Anchor to Better pattern: access metafields onceBetter pattern: access metafields once

If a metafield doesn't change between iterations, then read it once before the loop and reuse the variable:

{%- comment -%} Bad: reads the same collection metafield twice on every iteration {%- endcomment -%}
{% for product in collection.products %}
{% if collection.metafields.custom.badge_label != blank %}
<span class="badge">{{ collection.metafields.custom.badge_label }}</span>
{% endif %}
{{ product.title }}
{% endfor %}

{%- comment -%} Good: reads the collection metafield once, before the loop {%- endcomment -%}
{% assign badge_label = collection.metafields.custom.badge_label %}

{% for product in collection.products %}
{% if badge_label != blank %}
<span class="badge">{{ badge_label }}</span>
{% endif %}
{{ product.title }}
{% endfor %}

A metafield that changes on each iteration, such as product.metafields.custom.featured, can't be hoisted out of the loop. For those, reduce how many lookups each iteration makes by staying at the product level instead of the variant level.

Anchor to Avoid variant-level metafield loopsAvoid variant-level metafield loops

If you must check variant metafields, then minimize the checks:

{%- comment -%} Bad: checks metafields for every variant {%- endcomment -%}
{% for variant in product.variants %}
{% if variant.metafields.custom.field1 %}...{% endif %}
{% if variant.metafields.custom.field2 %}...{% endif %}
{% if variant.metafields.custom.field3 %}...{% endif %}
{% endfor %}

{%- comment -%} Good: checks only the selected variant {%- endcomment -%}
{% assign selected = product.selected_or_first_available_variant %}
{% if selected.metafields.custom.field1 %}...{% endif %}
{% if selected.metafields.custom.field2 %}...{% endif %}

Anchor to Limit metaobject loopsLimit metaobject loops

{%- comment -%} Good: limit the metaobject loop {%- endcomment -%}
{% for testimonial in metaobjects.testimonials.values limit: 3 %}
{{ testimonial.author.value }}
{% endfor %}

The key after metaobjects is the metaobject definition type, so metaobjects.<type>.values returns the entries for that definition. Use the top-level metaobjects object. The shop.metaobjects form is deprecated.

Anchor to Check metafield existenceCheck metafield existence

Check whether a metafield exists before using it to avoid errors, but minimize these checks in loops:

{%- comment -%} Good: check existence once {%- endcomment -%}
{% assign has_sizing_guide = product.metafields.custom.sizing_guide %}

{% if has_sizing_guide %}
<a href="{{ has_sizing_guide.value }}">View sizing guide</a>
{% endif %}

Anchor to Move to theme settingsMove to theme settings

For data that doesn't change per product, use theme settings instead of metafields:

{%- comment -%} Good: checks the theme setting once, outside the loop {%- endcomment -%}
{% if settings.show_new_badge %}
{% for product in collection.products %}
<span class="badge">New</span>
{% endfor %}
{% endif %}

If you compare a date to a theme setting, then normalize both sides first. There's no date input type for theme settings, so the setting is a string, and comparing it directly to product.created_at raises Liquid error: comparison of ActiveSupport::TimeWithZone with String failed on the storefront:

{%- comment -%} Good: compare Unix timestamps, not a date to a string {%- endcomment -%}
{% assign new_after = settings.new_product_date | date: '%s' | times: 1 %}
{% for product in collection.products %}
{% assign created_at = product.created_at | date: '%s' | times: 1 %}
{% if created_at > new_after %}
<span class="badge">New</span>
{% endif %}
{% endfor %}

Common antipattern: checking the metafield for every variant.

{%- comment -%} Slow {%- endcomment -%}
<select name="id">
{% for variant in product.variants %}
<option value="{{ variant.id }}">
{{ variant.title }}
{% if variant.metafields.custom.size_guide %}
- {{ variant.metafields.custom.size_guide.value }}
{% endif %}
</option>
{% endfor %}
</select>

{%- comment -%} Fast: product-level metafield {%- endcomment -%}
{% if product.metafields.custom.size_guide %}
<a href="{{ product.metafields.custom.size_guide.value }}">
Sizing guide
</a>
{% endif %}

<select name="id">
{% for variant in product.variants %}
<option value="{{ variant.id }}">
{{ variant.title }}
</option>
{% endfor %}
</select>
Caution

Both examples keep the variant <select> so that the only difference is where the metafield lookup happens. Don't copy that picker: product.variants returns at most 250 variants, so the picker silently omits options on high-variant products. Build pickers from product.options_with_values instead. Refer to Avoid over-fetching product variants.

Info

Metaobjects and metafields: Metafields extend existing resources, such as products, variants, and collections. Metaobjects create new custom resources, such as testimonials, FAQs, and ingredients. Both have performance costs when accessed in loops. Use metafields for product-specific data. Use metaobjects sparingly for custom content that needs to be managed separately.


  • Theme Inspector: Use the sandwich view to identify repetitive metafield access.
  • Compare TTFB: Test before and after removing nested metafield loops.
  • Test with realistic data: Use products that have metafields populated to see the real impact.


Was this page helpful?