Tags provide the control structures that make Liquid a true templating language rather than just a substitution engine. While keys access data, operators compare it, and filters transform it, tags control the flow and logic of your templates. They enable conditional rendering, loops, variable assignment, and other programmatic features essential for creating sophisticated notifications.

What are Liquid tags?

Tags are wrapped in curly braces with percent signs {% %} and provide programming constructs like conditionals, loops, and variable assignment. Unlike keys (which output data) or filters (which transform data), tags control what gets rendered and how.

There are three main categories of tags:

  • Control flow tags determine what content appears based on conditions.
  • Iteration tags loop through collections to generate repeated content.
  • Variable tags create and manipulate values within your template.

Let's explore each category in depth.

Control flow tags

Control flow tags let you show or hide content based on conditions, creating dynamic templates that adapt to your data.

The if tag

The if tag is your primary tool for conditional rendering. Use it when you want to display content only when certain conditions are true:

Data
{
  "user": {
    "subscription_status": "active",
    "subscription_end_date": "2024-12-31",
    "plan": "premium"
  }
}
Template
{% if user.subscription_status == "active" %}
✓ Your subscription is active until {{ user.subscription_end_date | date: "%B %d, %Y" }}.
{% endif %}

{% if user.plan == "premium" %}
You have access to all premium features!
{% endif %}
Output
✓ Your subscription is active until December 31, 2024.
You have access to all premium features!

The elsif and else tags

Use elsif and else to handle multiple conditions. Liquid evaluates each condition from top to bottom, executing the first block that matches and skipping the rest. You can have as many elsif branches as you need, and the final else acts as a catch-all for anything that didn't match the previous conditions.

Data
{
  "user": {
    "subscription_status": "trialing",
    "trial_days_remaining": 5
  }
}
Template
{% if user.subscription_status == "active" %}
Your subscription is active. Thank you for being a subscriber!
{% elsif user.subscription_status == "trialing" %}
Your trial ends in {{ user.trial_days_remaining }} days. Upgrade to continue!
{% elsif user.subscription_status == "expired" %}
Your subscription has expired. Renew to regain access.
{% else %}
Start your free trial today!
{% endif %}
Output
Your trial ends in 5 days. Upgrade to continue!

The unless tag

The unless tag is the inverse of if—it executes when a condition is false:

Data
{
  "user": {
    "email_verified": false,
    "profile_complete": false
  }
}
Template
{% unless user.email_verified %}
⚠️ Please verify your email address to unlock all features.
{% endunless %}

{% unless user.profile_complete %}
Complete your profile to get personalized recommendations.
{% endunless %}
Output
⚠️ Please verify your email address to unlock all features.
Complete your profile to get personalized recommendations.

The case and when tags

When you have multiple conditions based on a single value, case provides cleaner syntax than multiple if statements. Think of this like a multiple-choice question where you're checking "which one of these options matches?" Instead of asking "is it A? is it B? is it C?" repeatedly, you ask once and then list all the possible answers. Each when branch represents one possible value, and like if/elsif, Liquid executes only the first matching branch.

Data
{
  "notification": {
    "type": "payment_failed",
    "amount": 99.99
  }
}
Template
{% case notification.type %}
{% when "payment_success" %}
  ✓ Your payment of ${{ notification.amount }} was successful.
{% when "payment_failed" %}
  ✗ We couldn't process your payment of ${{ notification.amount }}. Please update your card.
{% when "subscription_renewed" %}
  ✓ Your subscription has been renewed for ${{ notification.amount }}.
{% when "refund_processed" %}
  A refund of ${{ notification.amount }} has been issued to your account.
{% else %}
  You have a new notification.
{% endcase %}
Output
✗ We couldn't process your payment of $99.99. Please update your card.

Iteration tags

Iteration tags let you loop through arrays and generate repeated content dynamically. Think of a loop like giving the same instruction multiple times, or "do this for each item in the list." Instead of manually writing the same code over and over for each item, you write it once and Liquid repeats it automatically for every element in your collection.

The for loop

The for tag iterates through arrays, executing the template content for each element. You're essentially saying "for each item in this list, do the following..." and Liquid takes care of going through each one.

When you write {% for item in order.items %}, you're creating a temporary name (item) that represents whichever element you're currently looking at in the loop. You can name this anything you want. It's just a label you use to access the current element. Inside the loop, you can use this name with dot notation (like item.name or item.price) to access the properties of the current element.

Data
{
  "order": {
    "items": [
      {
        "name": "T-Shirt",
        "price": 25,
        "quantity": 2
      },
      {
        "name": "Jeans",
        "price": 80,
        "quantity": 1
      },
      {
        "name": "Sneakers",
        "price": 120,
        "quantity": 1
      }
    ]
  }
}
Template
Order Summary:
{% for item in order.items %}
- {{ item.name }}: ${{ item.price }} × {{ item.quantity }} = ${{ item.price | times: item.quantity }}
{% endfor %}
Output
Order Summary:
- T-Shirt: $25.00 × 2 = $50.0
- Jeans: $80.00 × 1 = $80.0
- Sneakers: $120.00 × 1 = $120.0

Loop modifiers: limit and offset

When using a for loop on an array, you can use loop modifiers to control how many iterations run and where to start.

  • limit: restricts the number of iterations.
  • offset: skips a number of items before the loop begins.
Data
{
  "activities": [
    {
      "action": "Logged in",
      "time": "2024-11-01 09:00"
    },
    {
      "action": "Updated profile",
      "time": "2024-11-01 09:15"
    },
    {
      "action": "Made purchase",
      "time": "2024-11-01 10:30"
    },
    {
      "action": "Logged out",
      "time": "2024-11-01 11:00"
    },
    {
      "action": "Logged in",
      "time": "2024-11-02 08:00"
    }
  ]
}
Template
Recent Activities:
{% for activity in activities limit: 3 %}
{{ activity.action }} at {{ activity.time }}
{% endfor %}

Older Activities (skipping first 3):
{% for activity in activities offset: 3 %}
{{ activity.action }} at {{ activity.time }}
{% endfor %}
Output
Recent Activities (top 3):
Logged in at 2024-11-01 09:00
Updated profile at 2024-11-01 09:15
Made purchase at 2024-11-01 10:30

Older Activities (skipping first 3):
Logged out at 2024-11-01 11:00
Logged in at 2024-11-02 08:00

The forloop object

Inside a for loop, Liquid provides the forloop object with helpful metadata about the current iteration:

Data
{
  "team_members": [
    {
      "name": "Sarah",
      "role": "Engineer"
    },
    {
      "name": "Alex",
      "role": "Designer"
    },
    {
      "name": "Jordan",
      "role": "Product Manager"
    }
  ]
}
Template
{% for member in team_members %}
{% if forloop.first %}
  === Team Directory ===
{% endif %}

{{ forloop.index }}. {{ member.name }} - {{ member.role }}

{% if forloop.last %}
=== Total: {{ forloop.length }} members ===
{% endif %}
{% endfor %}
Output
=== Team Directory ===

1. Sarah - Engineer

2. Alex - Designer

3. Jordan - Product Manager

=== Total: 3 members ===

Available forloop properties:

  • forloop.index - Current iteration number (1-based)
  • forloop.index0 - Current iteration number (0-based)
  • forloop.first - true if this is the first iteration
  • forloop.last - true if this is the last iteration
  • forloop.length - Total number of iterations
  • forloop.rindex - Number of iterations remaining (counting down, 1-based)
  • forloop.rindex0 - Number of iterations remaining (counting down, 0-based)

Handling empty arrays with else

You can provide fallback content when a loop has no items to iterate:

Data
{
  "notifications": []
}
Template
{% for notification in notifications %}
- {{ notification.message }}
{% else %}
No new notifications to display.
{% endfor %}
Output
No new notifications to display.

Loop examples with conditionals

Combine loops with conditional logic for sophisticated rendering:

Data
{
  "notifications": [
    {
      "message": "New comment on your post",
      "unread": true,
      "priority": "low"
    },
    {
      "message": "Payment received",
      "unread": true,
      "priority": "high"
    },
    {
      "message": "Profile updated",
      "unread": false,
      "priority": "low"
    }
  ]
}
Template
{% for notification in notifications %}
{% if forloop.first %}📬 Notifications{% endif %}

{% if notification.priority == "high" %}🔴{% elsif notification.unread %}🔵{% else %}⚪{% endif %}
{{ forloop.index }}. {{ notification.message }}
{% if notification.unread %}[NEW]{% endif %}

{% if forloop.last %}
─────────────
Total: {{ forloop.length }} notifications
{% endif %}
{% endfor %}
Output
📬 Notifications

🔵

1. New comment on your post
 [NEW]

🔴 2. Payment received
[NEW]

⚪ 3. Profile updated

  ─────────────
  Total: 3 notifications

Variable tags

Variable tags let you create and manipulate values within your template, making complex templates more readable and maintainable.

The assign tag

Use assign to create variables that store values or computed results. Think of a variable like a labeled box where you can store a value to use later. Instead of writing a long calculation or accessing the same data multiple times, you calculate or grab it once, give it a name, and then reuse that name whenever you need it. This makes your templates easier to read and helps avoid repeating the same logic.

Data
{
  "product": {
    "price": 99.99,
    "discount_rate": 0.2,
    "tax_rate": 0.08
  }
}
Template
{% assign discount_amount = product.price | times: product.discount_rate %}
{% assign discounted_price = product.price | minus: discount_amount %}
{% assign tax_amount = discounted_price | times: product.tax_rate %}
{% assign final_price = discounted_price | plus: tax_amount %}

Original Price: ${{ product.price }}
Discount (20%): -${{ discount_amount | round: 2 }}
Subtotal: ${{ discounted_price | round: 2 }}
Tax: ${{ tax_amount | round: 2 }}
Final Price: ${{ final_price | round: 2 }}
Output
Original Price: $99.99
Discount (20%): -$20.00
Subtotal: $79.99
Tax: $6.40
Final Price: $86.39

The capture tag

The capture tag stores a block of content (which may include template logic) in a variable. While assign works with simple values or single calculations, capture is like putting together a whole paragraph or section, including text, keys, filters, and even conditional logic, and saving all of that as one complete piece that you can output later. It's particularly useful when you need to build complex text once and use it multiple times, or when you want to generate content conditionally before deciding where to display it.

Data
{
  "user": {
    "first_name": "Sarah",
    "last_login_days_ago": 45
  }
}
Template
{% capture greeting %}
{% if user.last_login_days_ago > 30 %}
  Welcome back, {{ user.first_name }}! It's been a while—we've missed you!
{% elsif user.last_login_days_ago > 7 %}
  Hey {{ user.first_name }}, good to see you again!
{% else %}
  Hi {{ user.first_name }}!
{% endif %}
{% endcapture %}

{{ greeting | strip }}
Output
Welcome back, Sarah! It's been a while—we've missed you!

Variables with arrays

You can use variables to simplify working with repeated array access:

Data
{
  "cart": {
    "items": [
      {
        "name": "Widget A",
        "price": 29.99
      },
      {
        "name": "Widget B",
        "price": 49.99
      },
      {
        "name": "Widget C",
        "price": 19.99
      }
    ]
  }
}
Template
{% assign total = 0 %}
{% for item in cart.items %}
{% assign item_price = item.price %}
{% assign total = total | plus: item_price %}
{% endfor %}

Cart Summary:
{% for item in cart.items %}

- {{ item.name }}: ${{ item.price }}
{% endfor %}
─────────────
Total: ${{ total }}
Output
Cart Summary:
- Widget A: $29.99
- Widget B: $49.99
- Widget C: $19.99
─────────────
Total: $99.97

Comment tags

Use comments to document your templates without affecting output:

{# This is a single-line comment #}
 
{% comment %}
  This is a multi-line comment.
  It can span multiple lines and won't appear in the output.
  Useful for explaining complex logic or temporarily disabling code.
{% endcomment %}
 
Hello {{ user.name }}!

Practical tag combinations

Real-world notification templates often combine multiple tag types. Here's a comprehensive example:

Data
{
  "user": {
    "name": "Alex",
    "plan": "pro",
    "usage_percentage": 85
  },
  "features": [
    {
      "name": "API Access",
      "enabled": true,
      "usage": 8500
    },
    {
      "name": "Advanced Analytics",
      "enabled": true,
      "usage": 230
    },
    {
      "name": "Priority Support",
      "enabled": false,
      "usage": 0
    }
  ]
}
Template
Hi {{ user.name }},

{% if user.usage_percentage >= 80 %}
⚠️ You've used {{ user.usage_percentage }}% of your {{ user.plan | upcase }} plan this month.

{% if user.usage_percentage >= 100 %}
You've exceeded your limit. Consider upgrading to avoid overages.
{% elsif user.usage_percentage >= 90 %}
You're approaching your limit. Upgrade now to avoid disruption.
{% else %}
You're on track to hit your limit before month-end.
{% endif %}
{% endif %}

Feature Usage:
{% for feature in features %}
{% if feature.enabled %}
✓ {{ feature.name }}: {{ feature.usage | format_number }} uses
{% else %}
✗ {{ feature.name }}: Not enabled on your plan
{% endif %}
{% endfor %}

{% assign enabled_count = 0 %}
{% for feature in features %}
{% if feature.enabled %}
{% assign enabled_count = enabled_count | plus: 1 %}
{% endif %}
{% endfor %}

You're using {{ enabled_count }} of {{ features.size }} available features.
Output
Hi Alex,

⚠️ You've used 85% of your PRO plan this month.

  You're on track to hit your limit before month-end.

Feature Usage:

  ✓ API Access: 8,500 uses


  ✓ Advanced Analytics: 230 uses


  ✗ Priority Support: Not enabled on your plan

You're using 2 of 3 available features.

Best practices for using tags

1. Keep conditional logic readable
Break complex conditions into multiple lines or use intermediate variables:

<!-- Less readable -->
{% if user.plan == "premium" and user.verified and user.payment_method or user.trial_days > 0 %}
 
<!-- More readable -->
{% assign is_premium_verified = user.plan == "premium" and user.verified and user.payment_method %}
{% assign has_trial = user.trial_days > 0 %}
{% if is_premium_verified or has_trial %}

Use case for multiple value checks

When checking a single variable against multiple values, case is cleaner than multiple if statements.

Provide else clauses in loops

Always handle the empty array case with an else clause in your for loops to prevent blank output.

Use variables to avoid repetition

If you're computing the same value multiple times, assign it to a variable once and reuse it.

Comment complex logic

Use comment tags to explain non-obvious conditional logic or calculations.

Tags are what transform Liquid from a simple substitution language into a full-featured templating system. They give you the power to create notifications that adapt intelligently to your data, handle edge cases gracefully, and maintain clean, readable template code.