Liquid filters are the workhorses of Shopify theme development. They transform output on the right side of a pipe character — turning raw data objects into properly formatted, store-ready strings. Yet most theme developers only ever reach for a handful of them, leaving a dozen powerful transformations undiscovered. This tutorial walks you through the 12 most valuable Liquid filters, grouped by purpose, with real-world snippets you can drop straight into your theme.
What you'll build
By the end of this tutorial you'll have a reference for 12 filters spanning six categories: text manipulation, money formatting, image handling, URL generation, array operations, and conditional helpers. Each group comes with working code you can paste into a snippet or section, plus the context for when each filter is the right choice.
Before you start
You'll need Shopify CLI 3.x installed, a development store with at least one product and collection, and a working knowledge of how Liquid template files are structured. All examples are compatible with Online Store 2.0 themes (Dawn or later). Run shopify theme dev against your dev store so you can preview filter output in real time.
Text filters: strip, replace, truncatewords, escape
Text filters clean up and reshape string values before they reach the browser. The strip filter removes leading and trailing whitespace — essential when you're pulling values from metafields or product descriptions that may carry hidden characters entered by merchants in the Shopify admin.
The replace filter is more useful than its simple signature suggests. Use it to swap out vendor-specific substrings in product titles, or to normalise SKUs. The truncatewords filter cuts a string at a word boundary, preventing the classic mid-word clip you get with plain truncate. Always prefer truncatewords for UI copy like card descriptions and meta descriptions.
The escape filter HTML-encodes special characters. It is mandatory whenever you interpolate user-supplied or merchant-supplied text into a data-* attribute or a JavaScript string — omitting it is an XSS vector. Shopify's own Theme Check linter will flag raw output in those contexts.
{%- comment -%}
strip — remove leading/trailing whitespace from a metafield value
{%- endcomment -%}
{%- assign clean_subtitle = product.metafields.custom.subtitle.value | strip -%}
{%- comment -%}
replace — swap 'ACME-' prefix out of SKUs for display
{%- endcomment -%}
{%- assign display_sku = product.selected_or_first_available_variant.sku | replace: 'ACME-', '' -%}
{%- comment -%}
truncatewords — safe 20-word excerpt with custom ellipsis
{%- endcomment -%}
<p class="card__excerpt">{{ product.description | strip_html | truncatewords: 20, '…' }}</p>
{%- comment -%}
escape — safe interpolation into a data attribute
{%- endcomment -%}
<div data-product-title="{{ product.title | escape }}"></div>Expected outcome: Product cards display clean, word-boundary-truncated excerpts and have safely escaped data attributes.
Money filters: money, money_with_currency
Prices in Shopify are stored as integers in the store's currency's smallest unit — cents for USD. The money filter divides by 100 and formats according to the store's currency settings. If your store sells in USD and product.price is 4999, running it through | money outputs $49.99.
Use money_with_currency whenever context is ambiguous — cart totals, order summaries, and any page where a customer from a different locale might land. It appends the ISO currency code (e.g. $49.99 USD), eliminating confusion on multi-currency markets. Do not hard-code the currency symbol; always delegate to the filter.
A common pattern is to output both prices in a compare-at price block: render the compare-at price with money inside a strikethrough element, and the sale price with a bold span. Note that product.compare_at_price is nil when no compare-at is set, so always guard with an if check.
<div class="price">
{%- if product.compare_at_price > product.price -%}
<s class="price--compare">{{ product.compare_at_price | money }}</s>
<strong class="price--sale">{{ product.price | money }}</strong>
{%- else -%}
<span class="price--regular">{{ product.price | money }}</span>
{%- endif -%}
</div>
{%- comment -%} Cart line total with currency code {%- endcomment -%}
<span class="line-total">{{ cart.total_price | money_with_currency }}</span>Expected outcome: Prices render correctly formatted in the store currency, with an optional compare-at line showing the discount.
Image filters: image_url, image_tag
In Online Store 2.0 themes, the modern image pipeline uses image_url (replacing the older img_url filter, which is now deprecated). The image_url filter generates a Shopify CDN URL with your desired width. It accepts named parameters: width, height, and crop (values: top, center, bottom, left, right).
Pair it with image_tag to automatically generate a full responsive <img> element with srcset, width, height, and loading="lazy" attributes. This is the Dawn-recommended way to render images and it eliminates the tedious manual srcset strings that cluttered older themes.
For theme settings images (those returned by settings.some_image when the setting type is image_picker), the same filter chain applies. The image object responds to .width and .height properties, so you can set the aspect-ratio CSS property dynamically and avoid layout shift.
{%- comment -%}
image_url + image_tag: responsive product image with lazy loading
{%- endcomment -%}
{%- liquid
assign image = product.featured_image
assign image_url = image | image_url: width: 800
-%}
{{ image | image_url: width: 800 | image_tag:
loading: 'lazy',
alt: image.alt | escape,
widths: '300, 400, 600, 800, 1000',
sizes: '(min-width: 768px) 50vw, 100vw'
}}
{%- comment -%}
Manual URL if you need to reference it in a style attribute or JSON
{%- endcomment -%}
<div
class="hero"
style="background-image: url('{{ settings.hero_image | image_url: width: 1400 }}')"
></div>Expected outcome: Product images have proper srcset attributes, lazy loading, and no layout shift due to missing dimensions.
URL filters: asset_url, file_url, link_to
The asset_url filter resolves a filename from your theme's assets/ folder to its full CDN URL with a content-based cache-bust hash appended. This means you reference files by name only — 'component-card.css' | asset_url — and Shopify handles the versioning for you. Never hard-code the CDN path; it changes between theme publishes.
The file_url filter resolves files uploaded through the Files section of the Shopify admin (Settings > Files). Use it to reference PDFs, downloadable content, or merchant-uploaded images that live outside the theme assets folder. The URL returned includes Shopify's CDN domain.
The link_to filter wraps any string in an <a> tag. Its signature is | link_to: url, title. It's most useful when building navigation from collections or linklist items, where you want to avoid writing the <a href> boilerplate repeatedly.
{%- comment -%} asset_url: reference theme CSS and JS safely {%- endcomment -%}
{{ 'base.css' | asset_url | stylesheet_tag }}
{{ 'theme.js' | asset_url | script_tag }}
{%- comment -%} file_url: merchant-uploaded PDF guide {%- endcomment -%}
{%- assign guide_url = 'size-guide.pdf' | file_url -%}
<a href="{{ guide_url }}" target="_blank">Download size guide</a>
{%- comment -%} link_to: turn a collection title into a link {%- endcomment -%}
{%- for collection in shop.collections limit: 5 -%}
<li>{{ collection.title | link_to: collection.url }}</li>
{%- endfor -%}Expected outcome: Asset CSS/JS loads with correct CDN cache-busted URLs; merchant-uploaded files link correctly; collection nav renders as anchor tags.
Array filters: where, map, default
The where filter selects items from an array where a named property equals a value. It's the Liquid equivalent of Array.filter() in JavaScript. A practical example: filtering a product's variants to find only those where available equals true, or filtering all products in a collection by a specific vendor.
The map filter extracts a single property from every item in an array, giving you a flat list. This is invaluable for building comma-separated tag lists, variant option labels, or metaobject field collections without verbose for loops.
The default filter returns a fallback value when its input is nil, false, or an empty string. Apply it defensively on metafield values and section settings that merchants might leave blank. Without it, you'll render empty elements that break your layout and look broken to shoppers.
{%- comment -%} where: only available variants {%- endcomment -%}
{%- assign available_variants = product.variants | where: 'available', true -%}
<p>{{ available_variants.size }} sizes in stock</p>
{%- comment -%} map: extract option names for a data attribute {%- endcomment -%}
{%- assign color_options = product.options_with_values | map: 'name' -%}
{%- comment -%} color_options is now ['Color', 'Size'] etc. {%- endcomment -%}
{%- comment -%} default: safe fallback for optional metafield {%- endcomment -%}
{%- assign badge_text = product.metafields.custom.badge_label.value | default: '' -%}
{%- unless badge_text == '' -%}
<span class="badge">{{ badge_text }}</span>
{%- endunless -%}Expected outcome: Variant count is accurate, option names are extracted cleanly, and the badge only renders when a metafield value is present.
Internationalisation and slug filters: t, weight_with_unit, handleize
The t filter looks up a translation key in your theme's locale files (locales/en.default.json etc.). It accepts interpolation variables: 'cart.item_count' | t: count: cart.item_count. Never hard-code user-facing strings in Liquid — always use the t filter, even for English-only stores, because it paves the way for future localisation and makes your theme compatible with Shopify's translation apps.
The weight_with_unit filter formats a variant's weight in the store's configured unit system (grams or pounds). It takes the raw weight integer and returns a human-readable string like 1.5 kg or 3.31 lb based on locale settings, sparing you from writing unit-conversion logic in the theme.
The handleize filter (also available as slugify in some contexts) converts any string to a URL-safe handle by lowercasing it, replacing spaces and special characters with hyphens, and removing non-alphanumeric characters. It is the correct way to generate CSS class names or HTML id attributes from dynamic content like product option names or collection titles.
{%- comment -%} t filter with interpolation {%- endcomment -%}
<p aria-live="polite">
{{ 'cart.general.items_in_cart' | t: count: cart.item_count }}
</p>
{%- comment -%} weight_with_unit: variant shipping weight {%- endcomment -%}
{%- if product.selected_or_first_available_variant.weight > 0 -%}
<span class="variant-weight">
{{ product.selected_or_first_available_variant.weight | weight_with_unit }}
</span>
{%- endif -%}
{%- comment -%} handleize: safe class names from option values {%- endcomment -%}
{%- for option in product.options_with_values -%}
{%- assign option_class = option.name | handleize -%}
<fieldset class="option option--{{ option_class }}">
<legend>{{ option.name }}</legend>
{%- for value in option.values -%}
<label class="swatch swatch--{{ value | handleize }}">{{ value }}</label>
{%- endfor -%}
</fieldset>
{%- endfor -%}Expected outcome: All user-facing strings come from locale files, variant weights display in the correct unit, and option fieldsets have safe, predictable CSS class names.
What's next
The 12 filters covered here represent the highest-leverage subset, but the full Liquid filter reference documents dozens more. A natural next step is exploring how filters interact with metafields and metaobjects — particularly the metafield.value property and how structured metaobject references expose nested fields for rendering in Liquid templates.
