Skip to main content
Innovatrix Infotech — home
Shopify Liquid Developer Reference: The Patterns We Use on Every Build cover
Shopify

Shopify Liquid Developer Reference: The Patterns We Use on Every Build

Shopify's official Liquid docs are fragmented across four different pages and never explain the 'why' behind architectural decisions. This is the single reference we wish existed when we started building themes — the patterns, filter chains, and schema tricks we use on every Innovatrix build.

Photo of Rishabh SethiaRishabh SethiaFounder & CEO5 October 202518 min read2.7k words
#shopify liquid#liquid template#shopify development#theme development#shopify 2.0#liquid filters#liquid objects

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 handle vs. id vs. gid distinction 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

Photo of Rishabh Sethia
Rishabh Sethia

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
Get started

Ready to talk about your project?

Whether you have a clear brief or an idea on a napkin, we'd love to hear from you. Most projects start with a 30-minute call — no pressure, no sales pitch.

No upfront commitmentResponse within 24 hoursFixed-price quotes