Shopify Liquid Developer Reference: The Patterns We Use on Every Build
Shopify's official Liquid documentation is spread across four separate pages. The open-source Liquid reference covers generic syntax. The Shopify-specific reference covers objects and filters. The theme architecture docs explain templates. And the schema reference lives in yet another corner. None of them explain why you'd choose one approach over another.
After building 50+ Shopify themes as an Official Shopify Partner, we compiled the reference we wish existed when we started. Every pattern here comes from production code — not theoretical examples.
What You'll Learn
- Liquid object hierarchy and when to use global vs. template-specific objects
- Filter chaining patterns that eliminate conditional logic
- The
handlevs.idvs.giddistinction and when each matters {% render %}vs.{% include %}— why this matters for performance- The
{% liquid %}tag for clean multi-line logic - Schema settings architecture for Online Store 2.0
- Forloop variables that save dozens of lines of conditional code
- The 5 Liquid patterns we use on every Innovatrix theme build
Prerequisites
- Working knowledge of HTML and CSS
- A Shopify Partner account (free to create)
- Shopify CLI installed (
npm install -g @shopify/cli) - A development store to test against
- Basic familiarity with template languages (Jinja, Twig, or similar helps but isn't required)
1. The Liquid Object Hierarchy: Global vs. Template-Specific
Liquid objects fall into two categories, and understanding this split prevents the most common beginner mistakes.
Global objects are available on every page:
{{ shop.name }} {%- comment -%} Always available {%- endcomment -%}
{{ request.locale }} {%- comment -%} Current locale {%- endcomment -%}
{{ settings.color_primary }} {%- comment -%} Theme settings {%- endcomment -%}
{{ cart.item_count }} {%- comment -%} Cart data, everywhere {%- endcomment -%}
{{ content_for_header }} {%- comment -%} Required in layout {%- endcomment -%}
Template-specific objects only exist within their context:
{%- comment -%} Only on product.json templates {%- endcomment -%}
{{ product.title }}
{{ product.variants.first.price | money }}
{%- comment -%} Only on collection.json templates {%- endcomment -%}
{{ collection.products_count }}
{{ collection.sort_by }}
{%- comment -%} Only on article.json templates {%- endcomment -%}
{{ article.author }}
{{ article.published_at | date: "%B %d, %Y" }}
The mistake we see constantly: developers try to access product inside a collection template's section and get blank output with zero errors. Liquid fails silently. If you're getting empty output, check whether the object exists in your current template context first.
Pro tip from our builds: Use {{ template.name }} and {{ template.suffix }} to debug which template is rendering. We add this as a hidden HTML comment during development:
<!-- Template: {{ template.name }}.{{ template.suffix }} -->
2. Filter Chaining Patterns Most Developers Underuse
Filters are where Liquid's power hides. Most developers use | money and | img_url and stop there. These chains eliminate entire blocks of conditional logic.
Array manipulation chains
{%- comment -%} Get unique tags from all products in a collection, sorted alphabetically {%- endcomment -%}
{% assign all_tags = collection.products | map: 'tags' | join: ',' | split: ',' | uniq | sort %}
{%- comment -%} Find products with a specific tag {%- endcomment -%}
{% assign featured = collection.products | where: 'available', true | where: 'tags', 'featured' %}
{%- comment -%} Get the first 4 images from a product, skip the first one {%- endcomment -%}
{% assign gallery = product.images | slice: 1, 4 %}
String manipulation chains
{%- comment -%} Create a clean, truncated excerpt from HTML content {%- endcomment -%}
{{ article.content | strip_html | truncatewords: 30, '...' }}
{%- comment -%} Generate a CSS-safe class from a product type {%- endcomment -%}
{{ product.type | handleize | prepend: 'product-type--' }}
{%- comment -%} Format money without trailing zeros {%- endcomment -%}
{{ product.price | money_without_trailing_zeros }}
The | default filter — your safety net
{%- comment -%} Falls back gracefully if metafield is empty {%- endcomment -%}
{{ product.metafields.custom.subtitle | default: product.title }}
{%- comment -%} Safe image rendering with fallback {%- endcomment -%}
{% assign hero_image = section.settings.image | default: 'fallback.jpg' %}
When we built the FloraSoul India Shopify store, filter chains like these replaced over 40 lines of {% if %} blocks in the collection template alone. That cleanup contributed to a measurable improvement in Liquid render time — part of the work that drove their +41% mobile conversion improvement.
3. Handle vs. ID vs. GID — When to Use Each
This trips up every developer at some point.
| Identifier | Format | Use Case |
|---|---|---|
handle |
"blue-t-shirt" |
URL routing, Liquid lookups, human-readable references |
id |
7234567890 |
Storefront API, Cart API, admin links |
gid |
"gid://shopify/Product/7234567890" |
Admin API (GraphQL), metafield references |
{%- comment -%} Accessing a product by handle — most common in Liquid {%- endcomment -%}
{% assign featured_product = all_products['blue-t-shirt'] %}
{{ featured_product.title }}
{%- comment -%} Using ID for cart operations (JavaScript) {%- endcomment -%}
<button data-variant-id="{{ variant.id }}" class="add-to-cart">
Add to Cart
</button>
{%- comment -%} Handles for collection lookups in navigation {%- endcomment -%}
{% assign sale_collection = collections['summer-sale'] %}
{% if sale_collection.products_count > 0 %}
<a href="{{ sale_collection.url }}">Sale ({{ sale_collection.products_count }})</a>
{% endif %}
The gotcha nobody warns about: Handles are auto-generated from titles and de-duplicated. If you create two products called "Blue T-Shirt", the second gets blue-t-shirt-1. If you rename the first product, its handle does NOT change. Handles are permanent once created. We've debugged this for clients more times than we'd like to admit.
4. {% render %} vs. {% include %} — The Performance Reality
{% include %} is deprecated in Online Store 2.0 themes, but we still see it in production stores.
{%- comment -%} OLD — avoid this {%- endcomment -%}
{% include 'product-card' %}
{%- comment -%} NEW — use this {%- endcomment -%}
{% render 'product-card', product: product, show_vendor: section.settings.show_vendor %}
The critical difference: {% render %} creates an isolated scope. The snippet cannot access variables from the parent template unless you explicitly pass them. This is not a limitation — it is a feature.
Why this matters for performance:
- Isolated scope means Shopify can cache rendered snippets more aggressively
- No variable leakage between snippets prevents subtle bugs
- Explicit parameters make snippets self-documenting
- Theme Check (Shopify's linter) flags
{% include %}as an error in 2.0 themes
{%- comment -%} Pass only what the snippet needs {%- endcomment -%}
{% render 'price', price: product.price, compare_price: product.compare_at_price, currency: cart.currency.iso_code %}
{%- comment -%} Use 'for' parameter to render a snippet for each item {%- endcomment -%}
{% render 'product-card' for collection.products as product %}
5. The {% liquid %} Tag — Clean Multi-Line Logic
Before {% liquid %}, complex logic looked like this:
{% assign has_sale = false %}
{% for variant in product.variants %}
{% if variant.compare_at_price > variant.price %}
{% assign has_sale = true %}
{% break %}
{% endif %}
{% endfor %}
{% if has_sale %}
<span class="badge badge--sale">Sale</span>
{% endif %}
With {% liquid %}:
{% liquid
assign has_sale = false
for variant in product.variants
if variant.compare_at_price > variant.price
assign has_sale = true
break
endif
endfor
if has_sale
echo '<span class="badge badge--sale">Sale</span>'
endif
%}
No tag soup. No {% and %} on every line. Clean, readable, and easier to diff in pull requests. We switched our entire codebase to {% liquid %} blocks for logic-heavy sections and haven't looked back.
6. Schema Settings: The 2.0 Way
Schema defines what merchants can configure in the theme editor. Getting this right means fewer support tickets and happier clients.
{% schema %}
{
"name": "Featured Collection",
"tag": "section",
"class": "section-featured-collection",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "Featured Products"
},
{
"type": "collection",
"id": "collection",
"label": "Collection"
},
{
"type": "range",
"id": "products_to_show",
"label": "Products to show",
"min": 2,
"max": 12,
"step": 2,
"default": 4
},
{
"type": "select",
"id": "columns_desktop",
"label": "Desktop columns",
"options": [
{ "value": "2", "label": "2 columns" },
{ "value": "3", "label": "3 columns" },
{ "value": "4", "label": "4 columns" }
],
"default": "4"
}
],
"blocks": [
{
"type": "product_card_override",
"name": "Product Card Override",
"limit": 12,
"settings": [
{
"type": "product",
"id": "product",
"label": "Product"
},
{
"type": "text",
"id": "custom_badge",
"label": "Custom Badge Text"
}
]
}
],
"presets": [
{
"name": "Featured Collection"
}
]
}
{% endschema %}
Accessing these settings in Liquid:
<div class="grid grid--{{ section.settings.columns_desktop }}-col">
{% for product in collections[section.settings.collection].products limit: section.settings.products_to_show %}
{% render 'product-card', product: product %}
{% endfor %}
</div>
Our rule: Every section gets a presets array. Without it, the section won't appear in the theme editor's "Add section" menu. We've seen developers spend hours debugging why a section doesn't show up — it's almost always a missing preset.
7. Forloop Variables That Save Conditional Logic
The forloop object is more powerful than most developers realise.
{% for product in collection.products %}
{%- comment -%} Available forloop variables {%- endcomment -%}
{{ forloop.index }} {%- comment -%} 1-based index: 1, 2, 3... {%- endcomment -%}
{{ forloop.index0 }} {%- comment -%} 0-based index: 0, 1, 2... {%- endcomment -%}
{{ forloop.first }} {%- comment -%} true on first iteration {%- endcomment -%}
{{ forloop.last }} {%- comment -%} true on last iteration {%- endcomment -%}
{{ forloop.length }} {%- comment -%} total iterations {%- endcomment -%}
{{ forloop.rindex }} {%- comment -%} reverse index (1-based) {%- endcomment -%}
{{ forloop.rindex0 }} {%- comment -%} reverse index (0-based) {%- endcomment -%}
{% endfor %}
Real patterns we use:
{%- comment -%} Add comma-separated list without trailing comma {%- endcomment -%}
{% for tag in product.tags %}
{{ tag }}{% unless forloop.last %}, {% endunless %}
{% endfor %}
{%- comment -%} Add a divider between items but not after the last one {%- endcomment -%}
{% for block in section.blocks %}
{% render 'testimonial', block: block %}
{% unless forloop.last %}<hr class="divider">{% endunless %}
{% endfor %}
{%- comment -%} Alternate grid layout (2 columns with offset) {%- endcomment -%}
{% for product in collection.products %}
<div class="grid-item {% if forloop.index0 | modulo: 2 == 0 %}grid-item--left{% else %}grid-item--right{% endif %}">
{% render 'product-card', product: product %}
</div>
{% endfor %}
{%- comment -%} Lazy-load images after the first 4 (above the fold) {%- endcomment -%}
{% for product in collection.products %}
{% if forloop.index > 4 %}
{% assign lazy = 'lazy' %}
{% else %}
{% assign lazy = 'eager' %}
{% endif %}
{{ product.featured_image | image_url: width: 400 | image_tag: loading: lazy }}
{% endfor %}
8. The 5 Liquid Patterns We Use on Every Innovatrix Build
These are the patterns that made it into our internal starter theme because they solve problems we hit on literally every project.
Pattern 1: Responsive image with art direction
{%- comment -%} snippets/responsive-image.liquid {%- endcomment -%}
{%- liquid
assign image = image | default: section.settings.image
assign alt = alt | default: image.alt | default: ''
assign loading = loading | default: 'lazy'
assign widths = '375,550,750,1100,1500,1780,2000'
-%}
{%- if image != blank -%}
{{ image | image_url: width: 1500 | image_tag:
widths: widths,
sizes: sizes,
alt: alt,
loading: loading,
class: class
}}
{%- else -%}
{{ 'image' | placeholder_svg_tag: 'placeholder-svg' }}
{%- endif -%}
Pattern 2: Price display with compare-at logic
{%- comment -%} snippets/price.liquid {%- endcomment -%}
{%- liquid
assign price = price | default: product.price
assign compare_price = compare_price | default: product.compare_at_price
assign on_sale = false
if compare_price > price
assign on_sale = true
assign savings = compare_price | minus: price | times: 100.0 | divided_by: compare_price | round
endif
-%}
<div class="price {% if on_sale %}price--on-sale{% endif %}">
<span class="price__current">{{ price | money }}</span>
{%- if on_sale -%}
<span class="price__compare">
<s>{{ compare_price | money }}</s>
</span>
<span class="price__badge">-{{ savings }}%</span>
{%- endif -%}
</div>
Pattern 3: Section spacing utility
{%- comment -%} Consistent vertical spacing across all sections {%- endcomment -%}
{%- liquid
assign padding_top = section.settings.padding_top | default: 36
assign padding_bottom = section.settings.padding_bottom | default: 36
-%}
<style>
.section-{{ section.id }} {
padding-top: {{ padding_top }}px;
padding-bottom: {{ padding_bottom }}px;
}
@media (min-width: 750px) {
.section-{{ section.id }} {
padding-top: {{ padding_top | times: 1.5 | round }}px;
padding-bottom: {{ padding_bottom | times: 1.5 | round }}px;
}
}
</style>
Pattern 4: Metafield-powered content blocks
{%- comment -%} Pull structured data from metafields {%- endcomment -%}
{%- liquid
assign specs = product.metafields.custom.specifications.value
-%}
{%- if specs != blank -%}
<div class="product-specs">
{%- for spec in specs -%}
<div class="spec-row">
<dt>{{ spec.label }}</dt>
<dd>{{ spec.value }}</dd>
</div>
{%- endfor -%}
</div>
{%- endif -%}
Pattern 5: Collection filter URL builder
{%- comment -%} Build filter URLs without page reload {%- endcomment -%}
{%- liquid
assign current_sort = collection.sort_by | default: collection.default_sort_by
assign base_url = collection.url
-%}
<select data-sort-select onchange="window.location.href=this.value">
{%- for option in collection.sort_options -%}
<option
value="{{ base_url }}?sort_by={{ option.value }}"
{% if current_sort == option.value %}selected{% endif %}
>
{{ option.name }}
</option>
{%- endfor -%}
</select>
Common Issues and Fixes
Empty output with no error: Liquid fails silently. Check that you're accessing the correct object for your template context. Use {{ template.name }} to verify.
Whitespace issues in output: Use {%- and -%} (hyphenated delimiters) to strip whitespace. This is critical inside <head> tags and JSON-LD output.
Filter chain returning empty: The | where filter is case-sensitive. | where: 'tags', 'Sale' won't match a tag of 'sale'. Use | downcase before comparison.
Section not appearing in editor: Missing presets array in your schema. Every section that should be addable via the editor needs at least one preset.
{% render %} snippet can't access variables: This is by design. Pass every variable the snippet needs as a named parameter.
Frequently Asked Questions
Written by

Founder & CEO
Rishabh Sethia is the founder and CEO of Innovatrix Infotech, a Kolkata-based digital engineering agency. He leads a team that delivers web development, mobile apps, Shopify stores, and AI automation for startups and SMBs across India and beyond.
Connect on LinkedIn