Shopify .value Accessor: How Metafields Render in Liquid

A developer pasted product.metafields.custom.size_chart.value into a snippet on a teeth-whitening DTC build in March 2026, hit save, and the storefront broke. Empty <table> tags rendered on every product without a chart. Mobile Safari collapsed the layout. The fix was one missing word.

TL;DR: product.metafields.custom.size_chart.value returns the parsed object stored in a Shopify metafield. Without .value you get the raw drop. With .value you get the typed object you can iterate. The != blank guard on the parent path is mandatory: products without the metafield render an empty <table> that crashes layout on mobile Safari. The accessor changes shape per metafield type, so JSON returns a hash, rich_text returns HTML, product_reference returns a Product drop.

Why this matters for your store

  • One missing != blank guard on a 30-product catalog ships 30 empty <table> elements, which Mobile Safari treats as a 1px layout-shift each. CLS goes from 0.05 to 0.31 overnight.
  • Apparel stores using size_chart.value for variant data with a copied snippet from the docs see 15 to 30% of products fail silently because the docs example assumes the merchant filled every metafield.
  • Stores migrating from app-managed size charts to native metafields write 60 to 90 minutes of debugging time per developer if they miss the .value accessor on the first pass.

What does product.metafields.custom.size_chart.value return?

.value returns the parsed content of the metafield, typed according to the metafield definition. The Shopify admin lets you define custom.size_chart as a JSON, single line text, rich text, file reference, product reference, or list type. Each one returns a different shape from .value.

Metafield type .value returns Use in Liquid
json Hash (Liquid object) {% for row in chart %} to iterate rows
single_line_text_field String {{ chart }} to print directly
rich_text_field HTML string {{ chart }} to render formatted body
file_reference File drop `{{ chart.preview_image
product_reference Product drop {{ chart.title }}, {{ chart.handle }}

If size_chart is defined as json (the default for tabular data), .value returns a hash with whatever shape the merchant entered. For rich_text, .value returns Shopify’s rendered HTML, which is safe to print directly inside the body of a section. The Shopify metafields reference documents the full type table.

How does product.metafields.reviews.rating.value work?

The same .value accessor pattern applies to review-aggregate metafields. Stores using a custom rating system without a paid app commonly store the aggregate as product.metafields.reviews.rating.value, which returns the parsed numeric rating (e.g., 4.7). The guard pattern is identical:

{# snippets/star-rating.liquid #}
{%- if product.metafields.reviews.rating != blank -%}
  {%- assign rating = product.metafields.reviews.rating.value -%}
  {%- assign count = product.metafields.reviews.count.value | default: 0 -%}
  <div class="star-rating" aria-label="Rated {{ rating }} out of 5 ({{ count }} reviews)">
    {{ rating | round: 1 }} stars ({{ count }})
  </div>
{%- endif -%}

Test on the parent path (product.metafields.reviews.rating != blank), assign .value to a named variable, then output. Same shape, different namespace.

For the broader picture of where metafields fit alongside the rest of native Liquid, the Shopify Liquid best practices guide covers when to choose metafields over apps. For replacing app-managed size charts with native ones, the 15 Liquid snippets that replace Shopify apps post has the full size-chart snippet ready to paste.

Why the != blank guard is not optional

Run this on a catalog where 7 of 30 products have no size chart:

{# snippets/size-chart.liquid — broken #}
<table class="size-chart">
  <tbody>
    {%- for row in product.metafields.custom.size_chart.value -%}
      <tr><td>{{ row.size }}</td><td>{{ row.bust }}</td></tr>
    {%- endfor -%}
  </tbody>
</table>

On the 7 products without the metafield, product.metafields.custom.size_chart.value returns blank, the for loop produces zero rows, and you ship an empty <table><tbody></tbody></table>. Mobile Safari calculates the <table> element at 1px tall by default, then re-flows when the rest of the PDP loads. CLS goes through the floor. Lighthouse flags it. Most devs do not notice for a week.

The fix is one line:

{# snippets/size-chart.liquid — production #}
{%- if product.metafields.custom.size_chart != blank -%}
  {%- assign chart = product.metafields.custom.size_chart.value -%}
  <table class="size-chart">
    <tbody>
      {%- for row in chart -%}
        <tr><td>{{ row.size }}</td><td>{{ row.bust }}</td></tr>
      {%- endfor -%}
    </tbody>
  </table>
{%- endif -%}

Wrap the entire markup block. Never test on .value alone because some metafield types coerce blank to 0 or [] and slip through a naive if. Test on the parent path.

Where the .value accessor breaks down

Three places.

Inside section schema settings. Liquid metafield access does not work at all from a section’s schema JSON. The schema is parsed at build time, before product context exists. Read the metafield from the section body, never from the schema block. If a merchant needs to choose which size-chart metafield surfaces in a section, expose a product setting and read the metafield off the chosen product inside the section body.

Inside stale Section Rendering API responses. A metafield value updated in admin will not appear in the rendered fragment until the URL-plus-query-string cache rotates. On Plus stores under flash-sale traffic, this can lag by 30 minutes or more. The visible symptom: merchant updates a size-chart in admin, hits a hard refresh, sees the old chart. The fix is a cache buster on the AJAX call, not on the snippet itself.

Inside {% render %} snippets without explicit product passing. The render tag isolates scope, so product is not available unless you pass it explicitly with {% render 'size-chart', product: product %}. A snippet that worked under the deprecated include tag will silently break under render. The Liquid console error is generic; the symptom is a snippet that renders empty without a stack trace.

For the broader picture of how render scope isolation interacts with metafield access, see Liquid render tag syntax. For the operator-precedence trap that bites when you combine multiple metafield checks in a single if, see Liquid operator precedence.

When to use .value versus the raw drop

The decision is straightforward but worth stating explicitly because new Liquid devs default to the wrong one.

Use .value when you need the parsed content for output or iteration. JSON metafields, rich_text metafields, file_reference metafields, and product_reference metafields all need .value to expose their typed content. This is the case 95 percent of the time you write a metafield-driven snippet.

Use the raw drop (without .value) when you need to inspect the metafield itself: its type field for runtime branching, its key for debug output, or its existence for the != blank guard. The guard is the most common case where you reach for the parent path instead of .value. Code-style consistency: always test the parent path with != blank, then assign .value to a named variable for use inside the block.

{# the canonical pattern #}
{%- if product.metafields.custom.size_chart != blank -%}
  {%- assign chart_meta = product.metafields.custom.size_chart -%}
  {%- assign chart_value = chart_meta.value -%}
  {# render with chart_value #}
{%- endif -%}

That pattern survives every metafield type the merchant defines later because the guard does not depend on the type’s coercion behaviour.

Performance: when .value becomes the bottleneck

For a single PDP, calling product.metafields.custom.size_chart.value is essentially free. The cost shows up under iteration. A collection page rendering 48 product cards that each call .value on a json metafield runs 48 parse operations per page render, not 48 cache reads. On a Shopify Plus store with 5,000 daily collection-page views, that compounds.

The pattern that ships: cache the parsed value at the top of the loop, not inside it.

{# slow: parses 48 times #}
{%- for p in collection.products -%}
  {%- if p.metafields.custom.size_chart != blank -%}
    {{ p.metafields.custom.size_chart.value.size_us }}
  {%- endif -%}
{%- endfor -%}

{# fast: assigns once per product #}
{%- for p in collection.products -%}
  {%- assign chart = p.metafields.custom.size_chart -%}
  {%- if chart != blank -%}
    {%- assign size = chart.value.size_us -%}
    {{ size }}
  {%- endif -%}
{%- endfor -%}

The named-variable pattern saves around 2 to 4 ms per card under Time to First Byte profiling on Shopify Plus, which compounds to 100 to 200 ms on a 50-product collection page. That difference is the gap between LCP under 2.5 s and LCP over.

For deeper Liquid loop optimisation patterns alongside metafield access, see the Liquid loop optimization guide.

How to verify your metafield access in 5 minutes

  1. Open Shopify admin, navigate to Settings, then Custom data, then Products. Confirm size_chart is defined under the custom namespace and note its type (JSON, rich text, etc.).
  2. Add a debug print above your snippet: <pre>{{ product.metafields.custom.size_chart | json }}</pre>. Reload the PDP. If you see null, the metafield is blank on this product, which is the case the != blank guard handles.
  3. Walk three products: one with the metafield filled, one without, one with a partial value. Confirm the size chart renders only on the first, with no empty <table> on the others.

If Lighthouse flags CLS above 0.1 after the fix, audit other metafield accesses in the same snippet for the same missing guard. The pattern repeats.

The takeaway

  • .value returns the parsed metafield content. Without it you get the raw drop and the snippet breaks.
  • The != blank guard goes on the parent path, never on .value. Always wrap the entire output.
  • Metafield types determine return shape: json returns a hash, rich_text returns HTML, references return drops.
  • Section schema settings cannot reference metafields. Read them in the section body.
  • Audit every metafield access in your theme this week for the missing guard.

Frequently Asked Questions

What does product.metafields.custom.size_chart.value return in Shopify Liquid?

`product.metafields.custom.size_chart.value` returns the parsed object stored in the metafield. Without `.value` you receive the raw JSON or string Shopify holds in the database. With `.value` you receive the typed object Liquid can iterate, render, or interpolate. For a `json` metafield definition, `.value` returns a hash. For `rich_text`, it returns the rendered HTML string. For `product_reference`, it returns the linked Product drop. The accessor changes shape depending on the metafield type, so always check the type in the Shopify admin before writing the snippet.

Do I need the != blank guard on a metafield access?

Yes. Products without the metafield set will return `blank` from `product.metafields.custom.size_chart`, which renders as an empty string. If you `for size in product.metafields.custom.size_chart.value` without a guard, you get an empty `<table>` tag that Mobile Safari renders as a 1-pixel-tall layout shift on every PDP. The guard is a single line: `{% if product.metafields.custom.size_chart != blank %}`. Wrap the entire render block. Never test on `.value` alone, because some metafield types coerce blank to `0` or `[]` and break the conditional.

What is the difference between metafields.custom.size_chart and metafields.custom.size_chart.value?

`product.metafields.custom.size_chart` returns the metafield drop itself: an object containing `type`, `key`, `namespace`, and `value`. `product.metafields.custom.size_chart.value` returns just the parsed content. For most theme code you want `.value`. The exception is when you need to inspect the metafield type from Liquid (rare, usually only inside debug snippets). The Shopify admin defines the `custom` namespace by default for new metafield definitions, so the path is consistent across stores unless someone changed it.

Why does product.metafields.custom.size_chart.value return null for some products?

Three causes cover most cases. First, the metafield definition exists at the shop level but the value is blank on that specific product, so the merchant never filled it in. Second, the metafield namespace was changed from `custom` to a custom namespace, in which case the path becomes `product.metafields.your_namespace.size_chart.value`. Third, the metafield was deleted in admin but cached values still reference the old key. Audit by opening any product in admin, scrolling to the Metafields block, and confirming the namespace and key match the path you wrote in Liquid.

Can I use product.metafields.custom.size_chart.value inside a section schema?

No. Section schema settings cannot reference metafields. Liquid metafield access works only inside the section template body, snippets rendered from a template, or the layout. If a merchant needs to choose which size chart shows on a section, expose a `product` setting in the schema and read the metafield off the chosen product inside the section markup. This pattern is the basis of every reusable size-chart, spec-table, and care-instructions section I have shipped on Shopify Online Store 2.0 themes since 2023.

Book Strategy Call