Shopify Metafields: Why Your Value Renders as Escaped JSON
You added a metafield, output it in Liquid, and got a wall of quotes and brackets. Here is what metafield_tag does and when you still need the raw value.
Disclosure: I spent four years building Shopify themes at Debutify, finishing as CDO.
The short answer
{{ product.metafields.custom.care_instructions }}
Output:
{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","value":"Machine wash cold"}]}]}
You are outputting a rich text metafield's raw value. Use the filter that renders it:
{{ product.metafields.custom.care_instructions | metafield_tag }}
metafield_tag inspects the metafield's type and emits the correct markup: paragraphs for rich text, an img for a file reference, a formatted string for a date, a link for a URL.
If you need just the text without wrapping markup, use .value and handle the type yourself.
Tested against Shopify Online Store 2.0, 2026.
The three things a metafield object gives you
This is the part that clears up most confusion. A metafield is an object, not a string, and it has three useful properties.
{{ product.metafields.custom.badge }} {%- comment -%} the object, stringified {%- endcomment -%}
{{ product.metafields.custom.badge.value }} {%- comment -%} the typed value {%- endcomment -%}
{{ product.metafields.custom.badge.type }} {%- comment -%} e.g. single_line_text_field {%- endcomment -%}
Outputting the object directly calls Liquid's default stringification, which for structured types gives you the internal JSON. That is the bug in almost every case.
For simple types, .value is what you want:
{{ product.metafields.custom.badge.value }}
For rich text, file references, dates, and measurements, .value is still a structure and you want metafield_tag.
Behaviour by type
| Type | .value returns |
Use |
|---|---|---|
single_line_text_field |
String | .value |
multi_line_text_field |
String with newlines | .value \| newline_to_br |
rich_text_field |
Nested object | metafield_tag |
number_integer |
Integer | .value |
boolean |
true or false | .value in a condition |
date |
Date string | .value \| date: "%d %b %Y" |
file_reference |
File object | metafield_tag or .value \| image_url |
product_reference |
Product object | .value.title, .value.url |
list.single_line_text_field |
Array | iterate |
json |
Parsed object | access properties directly |
dimension |
Object with value and unit | .value.value and .value.unit |
The reference types are the ones worth dwelling on, because they are the most useful feature and the least obvious.
Reference metafields
A product_reference metafield gives you an actual product object, fully resolved:
{%- assign rec = product.metafields.custom.recommended_pairing.value -%}
{%- if rec -%}
<a href="{{ rec.url }}">
<img src="{{ rec.featured_image | image_url: width: 300 }}"
alt="{{ rec.featured_image.alt | escape }}"
width="300" height="300" loading="lazy">
<span>{{ rec.title }}</span>
<span>{{ rec.price | money }}</span>
</a>
{%- endif -%}
This is genuinely powerful and it comes with a performance warning. Each reference resolution has a cost, and resolving references inside a loop over a collection is exactly the N+1 pattern that adds seconds to a page.
A list of references multiplies it:
{%- comment -%} 50 products, each with 4 references, is 200 resolutions {%- endcomment -%}
{%- for p in collection.products -%}
{%- for ref in p.metafields.custom.related.value -%}
{{ ref.title }}
{%- endfor -%}
{%- endfor -%}
Check your render time before and after adding reference metafields to a collection template:
curl -sI https://yourstore.com/collections/all | grep -i server-timing
Lists
List types return arrays and need iteration:
{%- assign features = product.metafields.custom.features.value -%}
{%- if features.size > 0 -%}
<ul>
{%- for f in features -%}
<li>{{ f }}</li>
{%- endfor -%}
</ul>
{%- endif -%}
Note features.size > 0 rather than if features. An empty list is truthy in Liquid, so the naive check renders an empty ul.
For a list of file references:
{%- for img in product.metafields.custom.gallery.value -%}
<img src="{{ img | image_url: width: 800 }}"
width="800" height="{{ 800 | divided_by: img.aspect_ratio | round }}"
alt="" loading="lazy">
{%- endfor -%}
The empty check that catches people
{%- comment -%} wrong: the metafield object exists even when the value is empty {%- endcomment -%}
{%- if product.metafields.custom.note -%}
{%- comment -%} right {%- endcomment -%}
{%- if product.metafields.custom.note.value != blank -%}
A metafield definition that exists but has no value for this product still returns an object, which is truthy. Checking the object rather than the value renders empty markup on every product without the field set, which is how you get stray empty divs across a catalogue.
For rich text specifically, an "empty" rich text field can contain an empty paragraph node, so it is not blank even though it looks empty. If you need to be strict:
{%- assign txt = product.metafields.custom.note | metafield_text -%}
{%- if txt != blank -%}
{{ product.metafields.custom.note | metafield_tag }}
{%- endif -%}
metafield_text extracts the plain text, which is also what you want for meta descriptions and structured data where markup would be wrong.
Debugging a metafield that will not render
The fastest diagnostic, and the general technique for any Liquid problem:
<pre>{{ product.metafields.custom | json }}</pre>
That dumps every metafield in the namespace with its type and value. Nine times out of ten you immediately see one of three things.
The namespace or key is wrong. custom.care_instructions versus custom.care-instructions. Underscores and hyphens are both valid and they are different keys.
The definition exists but this product has no value. The key will be absent from the dump entirely.
The type is not what you assumed. Somebody created it as multi_line_text_field and you are treating it as rich text, or the reverse.
If the dump is empty for a namespace you know exists, check that the metafield definition has storefront access enabled. Definitions created through the Admin API without access.storefront set are invisible to Liquid, which produces a genuinely confusing situation where the value is clearly there in admin and absent in the theme.
Populating them at scale
Entering metafields by hand for 400 products is not a plan.
Matrixify or a similar bulk app handles CSV import and export, including metafields, and is the pragmatic answer for merchants.
The Admin GraphQL API for anything programmatic:
mutation setMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { key namespace value }
userErrors { field message code }
}
}
metafieldsSet handles up to 25 per call and is idempotent, so it is safe to retry. Always read userErrors, because the mutation returns HTTP 200 with errors inside the payload, which is a pattern that catches people who only check the status code.
Where metafields are the wrong tool
Two cases worth being clear about.
A flag you check on every card in a grid. Metafield resolution per product in a loop is expensive. If you are badging products in a collection view, tags come back with the product for free and are the better mechanism.
Data that changes frequently or is computed. Metafields are content, edited by merchants in admin. Stock levels, dynamic pricing, and anything derived belongs in the data it derives from, not copied into a metafield that will drift.
Metafields are excellent for rich per product content that a merchant owns and edits: care instructions, size guides, spec tables, ingredient lists, downloadable manuals. That is the use case they were designed for, and moving hardcoded theme content into them is the most valuable part of an Online Store 2.0 migration.