Liquid Basics
Liquid is the templating language that powers every Shopify theme. It bridges your HTML/CSS skills with Shopify's dynamic data — products, collections, cart, and more. This chapter teaches you everything you need to read, write, and debug Liquid code confidently.
Time estimate: 60 – 90 minutes
Tags:
Liquid
HTML
What Is Liquid?
Liquid is a template language created by Shopify and written in Ruby. It's designed to be safe (no arbitrary code execution), readable (close to natural language), and flexible (handles complex data structures).
Liquid has three fundamental building blocks:
Objects
Output data using double curly braces. Objects represent store data like products, collections, and settings.
Tags
Control flow and logic using curly braces with percent signs. Tags handle conditionals, loops, assignments, and more.
Filters
Transform output using the pipe character. Filters modify strings, numbers, dates, URLs, and more.
Objects — Outputting Data
Objects are the simplest Liquid concept. They output data to the page using
double curly braces {{ }}.
<!-- Store name -->
<h1>{{ shop.name }}</h1>
<!-- Product title -->
<h2>{{ product.title }}</h2>
<!-- Product price (formatted as money) -->
<span>{{ product.price | money }}</span>
<!-- Collection description -->
<div>{{ collection.description }}</div>
<!-- Current page title -->
<title>{{ page_title }}</title>
Objects use dot notation to access properties, just like JavaScript.
product.title accesses the title property of the
product object.
Important Global Objects
These objects are available on every page of the theme:
| Object | Description | Example Properties |
|---|---|---|
shop |
The entire store | shop.name, shop.url, shop.currency, shop.email |
settings |
Global theme settings | settings.color_primary, settings.font_body |
cart |
Current shopping cart | cart.item_count, cart.total_price, cart.items |
request |
Current request info | request.locale, request.host, request.path |
linklists |
Navigation menus | linklists.main-menu.links |
page_title |
Current page's title | Used in <title> tags |
canonical_url |
Canonical URL of current page | Used for SEO <link rel="canonical"> |
content_for_header |
Shopify-injected scripts | Required in <head> |
Page-Specific Objects
These objects are only available on their respective page types:
| Page Type | Available Object | Key Properties |
|---|---|---|
| Product page | product |
.title, .price, .description, .images, .variants, .url, .vendor, .type, .tags |
| Collection page | collection |
.title, .products, .description, .products_count, .image |
| Cart page | cart |
.items, .total_price, .item_count, .note |
| Blog page | blog |
.title, .articles, .tags |
| Article page | article |
.title, .content, .author, .published_at, .image |
| Search results | search |
.terms, .results, .results_count |
| Custom page | page |
.title, .content, .url |
When you're unsure what properties an object has, use the
Shopify Liquid Reference.
Bookmark it — you'll use it daily. You can also output an entire object for debugging:
{{ product | json }} will show all the product's data as JSON.
Tags — Logic and Control Flow
Tags use curly braces with percent signs {% %}.
They don't output anything visible — they control what gets rendered
and how.
Conditionals: if, elsif, else, unless
<!-- Basic if/else -->
{% if product.available %}
<button type="submit">Add to Cart</button>
{% else %}
<button disabled>Sold Out</button>
{% endif %}
<!-- Multiple conditions with elsif -->
{% if cart.item_count == 0 %}
<p>Your cart is empty</p>
{% elsif cart.item_count == 1 %}
<p>You have 1 item in your cart</p>
{% else %}
<p>You have {{ cart.item_count }} items in your cart</p>
{% endif %}
<!-- Unless (opposite of if) -->
{% unless product.title == blank %}
<h1>{{ product.title }}</h1>
{% endunless %}
<!-- Checking if a value exists -->
{% if product.featured_image %}
<img src="{{ product.featured_image | image_url: width: 600 }}"
alt="{{ product.featured_image.alt | escape }}">
{% endif %}
<!-- Compound conditions -->
{% if product.available and product.price > 0 %}
<span class="price">{{ product.price | money }}</span>
{% endif %}
{% if product.type == 'shirt' or product.type == 'pants' %}
<span class="badge">Clothing</span>
{% endif %}
Comparison Operators
| Operator | Meaning | Example |
|---|---|---|
== |
Equals | {% if product.type == 'shirt' %} |
!= |
Not equals | {% if product.vendor != 'Nike' %} |
> |
Greater than | {% if product.price > 5000 %} |
< |
Less than | {% if cart.item_count < 3 %} |
>= |
Greater or equal | {% if product.variants.size >= 2 %} |
<= |
Less or equal | {% if collection.products_count <= 10 %} |
contains |
String/array includes | {% if product.tags contains 'sale' %} |
and |
Logical AND | {% if a and b %} |
or |
Logical OR | {% if a or b %} |
Shopify stores prices in cents (or the smallest currency unit).
So $50.00 is stored as 5000. When comparing prices,
use the cents value: {% if product.price > 5000 %}. Use the
money filter for display: {{ product.price | money }}.
Case / When
{% case template.name %}
{% when 'product' %}
<!-- Product page specific content -->
{% when 'collection' %}
<!-- Collection page specific content -->
{% when 'index' %}
<!-- Home page specific content -->
{% else %}
<!-- Default content -->
{% endcase %}
Loops — Iterating Over Data
Loops are essential in Shopify themes. You'll loop through products, collections, images, variants, cart items, navigation links, and more.
The for Loop
<!-- Loop through products in a collection -->
<div class="product-grid">
{% for product in collection.products %}
<div class="product-card">
<h3>{{ product.title }}</h3>
<span>{{ product.price | money }}</span>
</div>
{% endfor %}
</div>
<!-- Loop through product images -->
<div class="product-gallery">
{% for image in product.images %}
<img
src="{{ image | image_url: width: 800 }}"
alt="{{ image.alt | escape }}"
loading="{% if forloop.first %}eager{% else %}lazy{% endif %}"
>
{% endfor %}
</div>
<!-- Loop through navigation links -->
<nav>
<ul>
{% for link in linklists.main-menu.links %}
<li>
<a href="{{ link.url }}"
{% if link.active %} class="active"{% endif %}>
{{ link.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>
The forloop Object
Inside every for loop, Liquid provides the special forloop
object with useful properties:
| Property | Description | Example Use |
|---|---|---|
forloop.index |
Current iteration (1-based) | Display item number |
forloop.index0 |
Current iteration (0-based) | CSS class names |
forloop.first |
true on first iteration |
Eager-load first image |
forloop.last |
true on last iteration |
Skip trailing comma |
forloop.length |
Total number of iterations | Show "X items" count |
{% for product in collection.products %}
<div class="product-card
{% if forloop.first %} product-card--featured{% endif %}
{% if forloop.last %} product-card--last{% endif %}"
>
<span class="product-number">{{ forloop.index }} of {{ forloop.length }}</span>
<h3>{{ product.title }}</h3>
</div>
{% endfor %}
Limit and Offset
<!-- Show only the first 4 products -->
{% for product in collection.products limit: 4 %}
<div class="product-card">{{ product.title }}</div>
{% endfor %}
<!-- Skip the first 2 products, show next 4 -->
{% for product in collection.products offset: 2 limit: 4 %}
<div class="product-card">{{ product.title }}</div>
{% endfor %}
<!-- Loop through a number range -->
{% for i in (1..5) %}
<span>Star {{ i }}</span>
{% endfor %}
Empty State with else
<!-- Handle empty collections gracefully -->
{% for product in collection.products %}
<div class="product-card">
<h3>{{ product.title }}</h3>
</div>
{% else %}
<p class="empty-state">No products found in this collection.</p>
{% endfor %}
Always handle empty states. Use {% else %} in for loops
or check {% if collection.products.size > 0 %} before rendering grids.
A blank page with no feedback is a terrible user experience.
Filters — Transforming Output
Filters modify the output of objects. They use the pipe character
| and can be chained together.
String Filters
<!-- Capitalize -->
{{ 'hello world' | capitalize }}
<!-- Output: Hello world -->
<!-- Upcase / Downcase -->
{{ product.title | upcase }}
{{ product.title | downcase }}
<!-- Replace -->
{{ product.title | replace: ' ', '-' }}
<!-- Truncate -->
{{ product.description | strip_html | truncate: 100 }}
<!-- Strips HTML tags, then limits to 100 characters -->
<!-- Strip HTML -->
{{ product.description | strip_html }}
<!-- Escape (for use in attributes) -->
<img alt="{{ product.title | escape }}">
<!-- Append / Prepend -->
{{ 'world' | prepend: 'hello ' }}
<!-- Output: hello world -->
<!-- Handle blank values -->
{{ product.vendor | default: 'Unknown Vendor' }}
Number Filters
<!-- Math operations -->
{{ 100 | plus: 50 }} <!-- 150 -->
{{ 100 | minus: 30 }} <!-- 70 -->
{{ 10 | times: 5 }} <!-- 50 -->
{{ 100 | divided_by: 3 }} <!-- 33 -->
{{ 100 | modulo: 3 }} <!-- 1 -->
<!-- Round -->
{{ 4.567 | round: 2 }} <!-- 4.57 -->
{{ 4.567 | ceil }} <!-- 5 -->
{{ 4.567 | floor }} <!-- 4 -->
Money Filters
<!-- Format price as money (uses store's format) -->
{{ product.price | money }}
<!-- Output: $25.00 -->
<!-- Money with currency code -->
{{ product.price | money_with_currency }}
<!-- Output: $25.00 USD -->
<!-- Money without trailing zeros -->
{{ product.price | money_without_trailing_zeros }}
<!-- Output: $25 (if price is $25.00) -->
<!-- Calculate sale percentage -->
{% if product.compare_at_price > product.price %}
{% assign discount = product.compare_at_price | minus: product.price %}
{% assign percentage = discount | times: 100 | divided_by: product.compare_at_price %}
<span class="sale-badge">-{{ percentage }}%</span>
{% endif %}
URL Filters
<!-- Asset URL (for files in assets/ folder) -->
{{ 'style.css' | asset_url }}
<!-- Output: //cdn.shopify.com/s/files/.../style.css -->
<!-- Stylesheet tag -->
{{ 'base.css' | asset_url | stylesheet_tag }}
<!-- Output: <link href="..." rel="stylesheet"> -->
<!-- Script tag -->
{{ 'global.js' | asset_url | script_tag }}
<!-- Output: <script src="..."></script> -->
<!-- Product image URL with size -->
{{ product.featured_image | image_url: width: 600 }}
{{ product.featured_image | image_url: width: 600, height: 400, crop: 'center' }}
<!-- Link to a specific page type -->
{{ product.url }} <!-- /products/product-handle -->
{{ collection.url }} <!-- /collections/collection-handle -->
{{ page.url }} <!-- /pages/page-handle -->
Image URL Filter (Critical Knowledge)
The image_url filter is one of the most important filters in Shopify
theme development. It generates optimized image URLs:
<!-- Basic responsive image -->
<img
src="{{ product.featured_image | image_url: width: 800 }}"
srcset="
{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w
"
sizes="(max-width: 768px) 100vw, 50vw"
alt="{{ product.featured_image.alt | escape }}"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
loading="lazy"
>
<!-- With cropping -->
{{ image | image_url: width: 400, height: 400, crop: 'center' }}
<!-- Available crop values: top, center, bottom, left, right -->
Always set width and height attributes on images. This
prevents Cumulative Layout Shift (CLS) — a Core Web Vital metric.
Without dimensions, the browser doesn't know how much space to reserve, causing
content to jump as images load.
Filter Chaining
Filters can be chained — the output of one filter becomes the input of the next:
<!-- Chain multiple filters -->
{{ product.description | strip_html | truncate: 150 | escape }}
<!-- Build a CSS class from a product title -->
{{ product.title | downcase | replace: ' ', '-' | prepend: 'product-' }}
<!-- "Summer Shirt" becomes "product-summer-shirt" -->
<!-- Format a date -->
{{ article.published_at | date: '%B %d, %Y' }}
<!-- Output: January 15, 2024 -->
Variables and Assignment
You can create local variables using assign and capture.
assign
<!-- Simple assignment -->
{% assign featured_product = collections.frontpage.products.first %}
<h2>{{ featured_product.title }}</h2>
<!-- Boolean flag -->
{% assign show_sale_badge = false %}
{% if product.compare_at_price > product.price %}
{% assign show_sale_badge = true %}
{% endif %}
{% if show_sale_badge %}
<span class="badge badge--sale">Sale</span>
{% endif %}
<!-- Calculate values -->
{% assign columns = section.settings.columns %}
{% assign column_width = 100 | divided_by: columns %}
<!-- String assignment with filter -->
{% assign product_handle = product.title | handleize %}
<div id="{{ product_handle }}">...</div>
capture
capture stores a block of rendered content into a variable:
<!-- Capture complex HTML into a variable -->
{% capture price_html %}
{% if product.compare_at_price > product.price %}
<s class="price--compare">{{ product.compare_at_price | money }}</s>
<span class="price--sale">{{ product.price | money }}</span>
{% else %}
<span class="price">{{ product.price | money }}</span>
{% endif %}
{% endcapture %}
<!-- Use the captured content -->
<div class="product-price">{{ price_html }}</div>
Use assign for simple values (strings, numbers, object references).
Use capture when you need to store rendered HTML or complex string
concatenations. capture processes Liquid inside it before storing the result.
render vs. include
Both tags insert snippets, but they work differently:
<!-- RENDER (recommended — creates isolated scope) -->
{% render 'product-card', product: product, show_vendor: true %}
<!-- INCLUDE (legacy — shares parent scope) -->
{% include 'product-card' %}
| Feature | render |
include |
|---|---|---|
| Variable scope | Isolated (must pass variables explicitly) | Shared (accesses parent variables) |
| Performance | ✓ Better (can be cached) | ✗ Slower |
| Recommended | ✓ Yes | ✗ Deprecated |
| Predictability | High (no side effects) | Low (can create unexpected variable conflicts) |
render, Not include
include is deprecated in Shopify themes. Always use render.
It creates an isolated scope, which means variables from the parent template don't
leak into the snippet and vice versa. This makes your code predictable and prevents
subtle bugs.
<!-- In your section: pass all needed variables explicitly -->
{% for product in collection.products %}
{% render 'product-card',
product: product,
show_vendor: section.settings.show_vendor,
show_sale_badge: true,
image_size: 400,
lazy_load: true
%}
{% endfor %}
<!-- In snippets/product-card.liquid: use the passed variables -->
<div class="product-card">
<img
src="{{ product.featured_image | image_url: width: image_size }}"
alt="{{ product.featured_image.alt | escape }}"
{% if lazy_load %}loading="lazy"{% endif %}
>
<h3>{{ product.title }}</h3>
{% if show_vendor %}
<span>{{ product.vendor }}</span>
{% endif %}
<span>{{ product.price | money }}</span>
</div>
Whitespace Control
Liquid tags produce whitespace in the output HTML. Use the hyphen syntax to strip whitespace:
<!-- Without whitespace control (leaves blank lines) -->
{% if product.available %}
<span>In Stock</span>
{% endif %}
<!-- With whitespace control (clean output) -->
{%- if product.available -%}
<span>In Stock</span>
{%- endif -%}
<!-- Also works with output tags -->
{{- product.title -}}
The hyphens (-) strip whitespace from the side they're on:
{%-strips whitespace before the tag-%}strips whitespace after the tag{%- -%}strips both sides
Use whitespace-stripping hyphens ({%- -%}) on logic tags like
if, for, assign, and capture.
This keeps your rendered HTML clean without random blank lines. Dawn uses this
pattern extensively — study its code for examples.
Comments
<!-- HTML comment: visible in page source -->
{% comment %}
Liquid comment: NOT visible in page source.
Use these for developer notes.
{% endcomment %}
{%- comment -%}
With whitespace stripping.
This is the preferred style.
{%- endcomment -%}
{% # Inline comment (Shopify Liquid only, newer syntax) %}
Use Liquid comments ({% comment %}) for developer notes —
they won't appear in the page source. Use HTML comments sparingly, as they increase
page size and are visible to anyone who views the source.
The section Object
Inside every section file, you have access to the section object.
This is how you access the settings and blocks defined in the schema:
<!-- Access section settings -->
<h2>{{ section.settings.heading }}</h2>
<!-- Unique section ID (for CSS scoping) -->
<div id="section-{{ section.id }}">
...
</div>
<!-- Loop through blocks -->
{% for block in section.blocks %}
<div {{ block.shopify_attributes }}>
{% case block.type %}
{% when 'heading' %}
<h2>{{ block.settings.heading }}</h2>
{% when 'text' %}
<p>{{ block.settings.text }}</p>
{% when 'button' %}
<a href="{{ block.settings.link }}" class="btn">
{{ block.settings.label }}
</a>
{% endcase %}
</div>
{% endfor %}
Practical Liquid Patterns
Here are real-world patterns you'll use constantly in theme development:
Sale Price Display
{%- if product.compare_at_price > product.price -%}
<div class="price price--on-sale">
<s class="price__compare">{{ product.compare_at_price | money }}</s>
<span class="price__current">{{ product.price | money }}</span>
{%- assign savings = product.compare_at_price | minus: product.price -%}
<span class="price__savings">Save {{ savings | money }}</span>
</div>
{%- else -%}
<div class="price">
<span class="price__current">{{ product.price | money }}</span>
</div>
{%- endif -%}
Responsive Image with srcset
{%- if product.featured_image -%}
{%- assign image = product.featured_image -%}
<img
src="{{ image | image_url: width: 800 }}"
srcset="
{{ image | image_url: width: 200 }} 200w,
{{ image | image_url: width: 400 }} 400w,
{{ image | image_url: width: 600 }} 600w,
{{ image | image_url: width: 800 }} 800w,
{{ image | image_url: width: 1000 }} 1000w
"
sizes="(max-width: 749px) calc(100vw - 40px), (max-width: 999px) 50vw, 33vw"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
loading="lazy"
>
{%- else -%}
<div class="placeholder-image">
{{ 'product-1' | placeholder_svg_tag: 'placeholder' }}
</div>
{%- endif -%}
Active Navigation Link
<nav class="main-nav">
<ul>
{%- for link in linklists.main-menu.links -%}
<li class="nav-item">
<a
href="{{ link.url }}"
class="nav-link{% if link.active %} nav-link--active{% endif %}{% if link.child_active %} nav-link--child-active{% endif %}"
{% if link.current %} aria-current="page"{% endif %}
>
{{ link.title | escape }}
</a>
{%- if link.links.size > 0 -%}
<ul class="nav-dropdown">
{%- for child_link in link.links -%}
<li>
<a href="{{ child_link.url }}"
class="nav-dropdown__link{% if child_link.active %} active{% endif %}">
{{ child_link.title | escape }}
</a>
</li>
{%- endfor -%}
</ul>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</nav>
Pagination
{%- paginate collection.products by 12 -%}
<div class="product-grid">
{%- for product in collection.products -%}
{% render 'product-card', product: product %}
{%- endfor -%}
</div>
{%- if paginate.pages > 1 -%}
<nav class="pagination" aria-label="Pagination">
{%- if paginate.previous -%}
<a href="{{ paginate.previous.url }}" aria-label="Previous page">
← Previous
</a>
{%- endif -%}
{%- for part in paginate.parts -%}
{%- if part.is_link -%}
<a href="{{ part.url }}">{{ part.title }}</a>
{%- else -%}
<span class="current" aria-current="page">{{ part.title }}</span>
{%- endif -%}
{%- endfor -%}
{%- if paginate.next -%}
<a href="{{ paginate.next.url }}" aria-label="Next page">
Next →
</a>
{%- endif -%}
</nav>
{%- endif -%}
{%- endpaginate -%}
Shopify limits the number of products returned in a single loop to 50.
Always wrap collection product loops in {% paginate %} tags. Without
pagination, stores with more than 50 products will have missing items.
Debugging Liquid
Debugging Liquid can be challenging since there's no console or debugger. Here are professional techniques:
Output as JSON
<!-- Output any object as JSON for inspection -->
<pre>{{ product | json }}</pre>
<!-- Check section settings -->
<pre>{{ section.settings | json }}</pre>
<!-- Check what blocks exist -->
<pre>{{ section.blocks | json }}</pre>
<!-- Inspect a specific variable -->
{% assign my_var = product.variants.first %}
<pre>{{ my_var | json }}</pre>
<!-- Wrap in a details element to collapse -->
<details>
<summary>Debug: product data</summary>
<pre style="font-size: 12px; max-height: 400px; overflow: auto;">
{{ product | json }}
</pre>
</details>
Debug Comments
<!-- DEBUG: product.available = {{ product.available }} -->
<!-- DEBUG: collection.products.size = {{ collection.products.size }} -->
<!-- DEBUG: section.blocks.size = {{ section.blocks.size }} -->
<!-- DEBUG: template.name = {{ template.name }} -->
1. Check if the object exists: {{ product | json }}
2. Check specific properties: {{ product.title }}
3. Test conditions separately: wrap if conditions in HTML comments
4. Use the browser's "View Source" to see rendered output
5. Check the Shopify CLI terminal for Liquid errors
6. Remove debug output before committing!
Common Liquid Mistakes
| Mistake | Problem | Fix |
|---|---|---|
{{ product.price }} without money filter |
Shows price in cents (e.g., "2500" instead of "$25.00") | {{ product.price | money }} |
Using include instead of render |
Variable scope leaks, harder to debug | Always use {% render %} |
Missing | escape on user content |
Potential XSS vulnerability in alt tags, attributes | {{ product.title | escape }} in attributes |
Forgetting {% endfor %} or {% endif %} |
Liquid error — page won't render | Always close your tags immediately after opening |
| Not handling blank/nil values | Empty HTML elements, broken layouts | Check with {% if value != blank %} or use | default: |
No paginate wrapper on collection loops |
Only first 50 products shown | Always use {% paginate collection.products by 12 %} |
Quick Reference Cheat Sheet
╔══════════════════════════════════════════════════════════╗
║ LIQUID CHEAT SHEET FOR SHOPIFY THEMES ║
╠══════════════════════════════════════════════════════════╣
║ ║
║ OUTPUT: {{ object.property }} ║
║ FILTER: {{ value | filter_name }} ║
║ LOGIC: {% if condition %} ... {% endif %} ║
║ LOOP: {% for item in array %} ... {% endfor %} ║
║ ASSIGN: {% assign var = value %} ║
║ CAPTURE: {% capture var %} ... {% endcapture %} ║
║ RENDER: {% render 'snippet', var: value %} ║
║ COMMENT: {% comment %} ... {% endcomment %} ║
║ STRIP WS: {%- tag -%} or {{- output -}} ║
║ PAGINATE: {% paginate array by 12 %} ... {% endpag %} ║
║ ║
║ COMMON FILTERS: ║
║ | money Format as currency ║
║ | image_url: width: Generate image URL ║
║ | asset_url CDN URL for asset files ║
║ | escape HTML-safe output ║
║ | strip_html Remove HTML tags ║
║ | truncate: 100 Limit string length ║
║ | date: '%B %d, %Y' Format dates ║
║ | default: 'fallback' Default value if blank ║
║ | handleize URL-safe string ║
║ | json Output as JSON ║
║ | stylesheet_tag Wrap in <link> tag ║
║ | script_tag Wrap in <script> tag ║
║ ║
║ SECTION ACCESS: ║
║ section.id Unique section ID ║
║ section.settings Section schema settings ║
║ section.blocks Section blocks array ║
║ block.type Block type identifier ║
║ block.settings Block schema settings ║
║ block.shopify_attributes Required for theme editor ║
║ ║
╚══════════════════════════════════════════════════════════╝
Key Takeaways
- Liquid has three building blocks: objects (
{{ }}), tags ({% %}), and filters (|) - Global objects like
shop,settings,cartare available everywhere - Page-specific objects like
productandcollectiondepend on the page type - Use
forloops withforloopproperties for iteration - Always handle empty states with
{% else %}in loops - Prices are in cents — always use the
moneyfilter for display - Use
rendernotincludefor snippets - Use whitespace-stripping hyphens
{%- -%}on logic tags - Debug with
| jsonfilter and HTML comments - Always use
paginatefor collection product loops
With Liquid fundamentals in place, you're ready to explore the complete folder structure of a Shopify theme and understand what every file does.