Reference - Liquid
Language: Liquid — Shopify flavour. Base-gem differences marked
Checked: 2026-08-16
Liquid is a sandboxed display language, not a programming language. It can only touch data the host explicitly handed it, it has no arithmetic operators, and it cannot define a function. Everything you can do is: output a value, pipe it through a filter, branch on it, or loop over it.
§0 The one idea
Liquid was built so that a shop owner’s template could run on Shopify’s servers without being able to do anything dangerous. Every design decision follows from that.
- No arbitrary code. No function definitions, no imports, no network calls, no filesystem, no
eval - No access to the host except through exposed objects. If Shopify didn’t hand you
product,productdoesn’t exist. There is no way to reach further - No operators for anything but comparison. Addition is a filter, not a
+ - Rendered server-side, once, into a string. There is no runtime afterwards. Nothing is reactive, nothing re-renders
vs JS: in JavaScript you write logic and reach out for data — imports, fetches, whatever’s in scope. In Liquid the data is handed to you at the door and your only job is deciding how to display it. Every time Liquid feels frustrating, it’s this: you’re trying to compute where you’re only allowed to present.
The practical consequence to internalise early: when something can’t be expressed in Liquid, the answer is usually to change what the host passes in, or to do it in JavaScript on the client — never to be clever in the template.
§1 The smallest working form
<h1>{{ product.title }}</h1>That’s a complete Liquid template. Two braces, an object path, done. Everything below is addition.
There are exactly two delimiters, and the difference is the whole syntax:
{{ product.title }} {% comment %} outputs a value {% endcomment %}
{% if product.available %} {% comment %} does something, outputs nothing {% endcomment %}{{ }}— output. Evaluates to a string and prints it{% %}— tag. Logic, assignment, control flow. Prints nothing itself
Anything that isn’t inside a delimiter is passed through untouched. A Liquid file is HTML with holes in it.
§2 Filters
A filter transforms a value. Pipe with |, arguments after :, multiple arguments comma-separated.
{{ product.title | upcase }}
{{ product.title | truncate: 20 }}
{{ product.title | truncate: 20, '…' }}Chain them left to right — output of one becomes input of the next:
{{ product.description | strip_html | truncatewords: 30 | capitalize }}The ones worth memorising:
| Filter | Does | Example → output |
|---|---|---|
default | Fallback for nil/false/empty string | {{ product.vendor | default: 'Unbranded' }} |
escape | HTML-escapes | {{ user_input | escape }} |
strip_html | Removes tags | {{ product.description | strip_html }} |
truncate | Cuts to N characters | {{ 'Hello world' | truncate: 8 }} → Hello… |
size | Length of string or array | {{ cart.items | size }} |
join | Array → string | {{ tags | join: ', ' }} |
split | String → array | {{ 'a,b,c' | split: ',' }} |
map | Pull one property from each item | {{ collection.products | map: 'title' }} |
where | Filter an array by property value | {{ collection.products | where: 'available', true }} |
sort | Sort by property | {{ collection.products | sort: 'price' }} |
uniq | Deduplicate | {{ tags | uniq }} |
date | Format a timestamp | {{ article.published_at | date: '%d %B %Y' }} |
plus minus times divided_by | Arithmetic | {{ 5 | plus: 3 }} → 8 |
round ceil floor abs modulo | More arithmetic | {{ 5.6 | round }} → 6 |
Arithmetic is a filter chain, and it reads backwards. {{ 10 | minus: 3 }} is 10 − 3, so the piped value is always the left operand. There is no + operator and never will be.
divided_by floors when the divisor is an integer. Verified behaviour:
{{ 20 | divided_by: 7 }} {% comment %} → 2 {% endcomment %}
{{ 20 | divided_by: 7.0 }} {% comment %} → 2.857142857142857 {% endcomment %}The type of the divisor decides. This silently destroys percentage calculations — {{ 45 | divided_by: 100 | times: 100 }} gives 0, not 45.
§3 Variables
{% assign discount = 20 %}
{% assign name = product.title %}
{{ name }}assign takes a single expression, optionally filtered:
{% assign sale_price = product.price | times: 0.8 | round %}capture assigns rendered output instead, which is how you build a string from markup:
{% capture label %}{{ product.title }} — {{ product.vendor }}{% endcapture %}
{{ label }}increment and decrement maintain their own counters, in a separate namespace from assign:
{% increment counter %} {% comment %} → 0 {% endcomment %}
{% increment counter %} {% comment %} → 1 {% endcomment %}They output as they increment, they start at 0, and a variable called counter created by assign is a different variable entirely. Rarely what you want; occasionally the only way to get a unique DOM id.
§4 Conditionals
{% if product.available %}
In stock
{% elsif product.tags contains 'preorder' %}
Available to pre-order
{% else %}
Sold out
{% endif %}elsif — not elseif, not else if.
Operators: == != > < >= <= and or contains.
contains works on strings and on arrays of strings:
{% if product.title contains 'Wool' %}…{% endif %}
{% if product.tags contains 'sale' %}…{% endif %}unless is if not, and reads better for guard clauses:
{% unless product.available %}Sold out{% endunless %}case for one variable against many values:
{% case product.type %}
{% when 'Shirt' %} Shirts guide
{% when 'Shoes', 'Boots' %} Footwear guide
{% else %} General guide
{% endcase %}The parentheses trap
Parentheses are invalid in Liquid conditions. You cannot group logic, and and/or are evaluated right to left — not by precedence, and not left to right as most languages do.
{% comment %} evaluated as: a and (b or c) — NOT (a and b) or c {% endcomment %}
{% if a and b or c %}There is no syntax to override it. When you need grouping, precompute into a boolean:
{% assign is_promo = false %}
{% if product.tags contains 'sale' or product.compare_at_price > product.price %}
{% assign is_promo = true %}
{% endif %}
{% if is_promo and product.available %}…{% endif %}Nesting if blocks is the other option and is usually clearer.
§5 Loops
{% for product in collection.products %}
{{ product.title }}
{% endfor %}With an empty case built in:
{% for product in collection.products %}
{{ product.title }}
{% else %}
Nothing here yet.
{% endfor %}Parameters, combinable:
{% for product in collection.products limit: 4 offset: 2 reversed %}Ranges — the one place parentheses are legal:
{% for i in (1..5) %}{{ i }}{% endfor %} {% comment %} → 12345 {% endcomment %}{% break %} and {% continue %} behave as expected.
The forloop object exists inside every loop:
| Property | Value |
|---|---|
forloop.index | Position, 1-based |
forloop.index0 | Position, 0-based |
forloop.rindex | Position counting from the end, 1-based |
forloop.first / forloop.last | Booleans |
forloop.length | Total iterations |
forloop.parentloop | The enclosing loop’s forloop, when nested |
{% for item in cart.items %}
{{ forloop.index }}. {{ item.title }}{% unless forloop.last %},{% endunless %}
{% endfor %}Shopify caps for at 50 iterations. Not a default you can raise — a hard limit. Beyond it you need {% paginate %}, which is a Shopify tag rather than a Liquid one:
{% paginate collection.products by 24 %}
{% for product in collection.products %}…{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}This is the constraint that most often forces a design change: you cannot iterate a whole catalogue in a template. Anything needing all products is a job for the host, an API, or the client.
§6 Snippets and scope
{% render 'product-card' %}Renders snippets/product-card.liquid. render is scope-isolated: the snippet cannot see variables from the parent template, and cannot modify the parent’s variables. Pass what it needs explicitly:
{% render 'product-card', product: product, show_vendor: true %}Loop directly, which binds each item to a named variable inside the snippet:
{% render 'product-card' for collection.products as product %}include — the deprecated equivalent
{% include 'product-card' %} does the same job with the opposite scoping: the snippet can read and overwrite parent variables. It’s deprecated by Shopify for exactly that reason — shared mutable scope is slower and makes templates unreadable — but it has not been removed and legacy themes are full of it.
render | include | |
|---|---|---|
| Sees parent variables | No | Yes |
| Can overwrite parent variables | No | Yes |
| Status | Current | Deprecated, still functional |
| Performance | Better — isolation allows caching | Worse |
Treat these as non-equivalent when refactoring. Swapping include for render in an old theme breaks any snippet that was silently relying on an outer variable, and the failure is a blank output rather than an error.
§7 Whitespace control
Tags leave their newlines behind, which wrecks output in <pre>, JSON blobs and email templates. A hyphen inside either delimiter strips adjacent whitespace:
{%- if product.available -%}
{{- product.title -}}
{%- endif -%}{%- strips before, -%} strips after. Same for {{- and -}}. Cosmetic in HTML, load-bearing anywhere whitespace is significant.
§8 The {% liquid %} block — same thing, less noise
Consecutive tags get unreadable. {% liquid %} lets you drop the per-line delimiters, and echo replaces {{ }}:
{% liquid
assign discount = 20
assign sale_price = product.price | times: 0.8 | round
if product.available
echo sale_price | money
else
echo 'Sold out'
endif
%}These two forms are exactly equivalent — the block below compiles to the same output as the one above:
{% assign discount = 20 %}
{% assign sale_price = product.price | times: 0.8 | round %}
{% if product.available %}{{ sale_price | money }}{% else %}Sold out{% endif %}Use the block for logic runs, the inline form when tags are interleaved with markup.
§9 Comments
{% comment %}
Block form. Anything inside is not rendered.
{% endcomment %}
{% # inline form, one line only %}
{% liquid
# inside a liquid block, every commented line needs its own hash
assign x = 1
%}§10 Truthiness — the footgun
Only nil and false are falsy. Everything else is truthy, including empty strings, 0, and empty arrays.
| Value | Truthy? |
|---|---|
nil | No |
false | No |
'' (empty string) | Yes |
0 | Yes |
[] (empty array) | Yes |
vs JS: JavaScript treats '', 0, NaN, null and undefined as falsy. Liquid does not. Every habit you have from if (str) is wrong here.
So this is always true, for every product:
{% if product.description %}…{% endif %} {% comment %} true even when '' {% endcomment %}And these are the correct forms:
{% if product.description != blank %}…{% endif %}
{% if cart.items.size > 0 %}…{% endif %}blank is a special object matching empty strings, empty arrays and nil at once. != blank is the check you actually want almost every time, and forgetting it is the single most common Liquid bug.
§11 Required vs optional
| Thing | Required? | Notes |
|---|---|---|
{% endif %}, {% endfor %}, {% endcapture %} | Yes | No self-closing forms, no implicit close |
| Quotes on string literals | Yes | 'sale' or "sale"; bare words are treated as variable names |
| Quotes on filter arguments | Only for strings | truncate: 20 vs default: 'None' |
| Space inside delimiters | No | {{product.title}} is valid; conventionally spaced |
{% else %} in if / for / case | No | |
Snippet file extension in render | No | render 'card' finds snippets/card.liquid |
Passing variables to render | Yes, if needed | Nothing is inherited |
| Whitespace hyphens | No | Cosmetic unless whitespace is significant |
| Parentheses in conditions | Not permitted | Invalid characters — will break the tag |
| Parentheses in ranges | Yes | (1..5) |
§12 What’s Liquid and what’s Shopify
The subtraction that matters — half of what looks like language is platform.
| Base Liquid (the gem) | Shopify adds | |
|---|---|---|
| Tags | if unless case for assign capture increment comment raw cycle tablerow | render paginate section layout form style javascript content_for |
| Filters | String, array, maths, date, default, escape | money money_with_currency asset_url image_url image_tag link_to t json weight_with_unit |
| Objects | Only what the host passes | product collection cart customer shop settings request template routes linklists all_products |
| Limits | None inherent | 50-iteration for cap, render depth limits, theme file size limits |
Jekyll is the other common host and exposes an entirely different object set (page, site, post) with the same language underneath. The language transfers; nothing else does.
Shopify object properties are platform surface and change — treat them as lookup, never memory. [CHECK: image_url / image_tag are the current image filters; the older img_url is legacy — confirm against current docs before relying on either in new work.]
One Shopify quirk that bites immediately: money values are integers in the currency’s subunit. product.price for a £29.99 item is 2999. Piping through money formats it; outputting it raw gives you a number 100× too large.
§13 Kitchen sink — everything at once
Deliberately overloaded. Nobody should write this; it exists to show the pieces interacting.
{%- liquid
assign promo_tag = 'sale'
assign is_promo = false
if product.tags contains promo_tag
assign is_promo = true
endif
assign visible = product.variants | where: 'available', true
-%}
{%- if visible.size > 0 -%}
<ul class="variants{% if is_promo %} variants--promo{% endif %}">
{%- for variant in visible limit: 10 -%}
<li{% if forloop.first %} class="first"{% endif %}>
{{ forloop.index }}.
{{ variant.title | escape }} —
{{ variant.price | money }}
{%- unless forloop.last -%},{%- endunless -%}
</li>
{%- else -%}
<li>{{ 'products.none' | t | default: 'No variants' }}</li>
{%- endfor -%}
</ul>
{%- else -%}
<p>{{ product.title | truncate: 40 }} is sold out.</p>
{%- endif -%}§14 One complete file, top to bottom
sections/featured-collection.liquid — a realistic Shopify section, every construct earning its place.
{% comment %}
Renders a grid of products from a chosen collection.
Expects section settings: collection, heading, products_to_show.
{% endcomment %}
{%- liquid
assign collection = collections[section.settings.collection]
assign limit = section.settings.products_to_show | default: 8
-%}
<section class="featured-collection">
{%- if section.settings.heading != blank -%}
<h2>{{ section.settings.heading | escape }}</h2>
{%- endif -%}
{%- if collection == blank -%}
<p>Choose a collection in the theme editor.</p>
{%- else -%}
<div class="grid">
{%- for product in collection.products limit: limit -%}
{%- render 'product-card',
product: product,
show_vendor: section.settings.show_vendor -%}
{%- else -%}
<p>This collection is empty.</p>
{%- endfor -%}
</div>
<a href="{{ collection.url }}">
View all {{ collection.products_count }} products
</a>
{%- endif -%}
</section>
{% schema %}
{
"name": "Featured collection",
"settings": [
{ "type": "collection", "id": "collection", "label": "Collection" },
{ "type": "text", "id": "heading", "label": "Heading", "default": "Featured" },
{ "type": "range", "id": "products_to_show", "min": 2, "max": 12, "step": 2, "default": 8 },
{ "type": "checkbox", "id": "show_vendor", "label": "Show vendor", "default": false }
],
"presets": [{ "name": "Featured collection" }]
}
{% endschema %}Note the {% schema %} block: it is not Liquid. It’s JSON that Shopify parses out of the file before rendering, and it’s what puts the settings in the theme editor. Nothing in it is available to Liquid except via section.settings.
Canonical docs: Liquid language reference for the base gem, Shopify Liquid reference for objects, filters and limits.