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
!= blankguard 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.valuefor 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
.valueaccessor 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
- Open Shopify admin, navigate to Settings, then Custom data, then Products. Confirm
size_chartis defined under thecustomnamespace and note its type (JSON, rich text, etc.). - Add a debug print above your snippet:
<pre>{{ product.metafields.custom.size_chart | json }}</pre>. Reload the PDP. If you seenull, the metafield is blank on this product, which is the case the!= blankguard handles. - 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
.valuereturns the parsed metafield content. Without it you get the raw drop and the snippet breaks.- The
!= blankguard 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.