Skip to content

Module 14: Loops — for / endfor

A loop is an instruction that says: “For each item in this list, do the following.” It takes a template (the body of the loop) and repeats it once per item, substituting the current item each time.

Excel analogy: Imagine you have a column of feature names (A1:A10) and you want to create a numbered list in column B. You’d write a formula in B1 like =ROW()&". "&A1, then copy it down to B10. A for loop does the same thing — it takes one template and applies it to every item automatically.

Customer data often includes lists — features used, open tasks, products purchased, regions served. Loops let you display these lists dynamically, adapting to however many items each customer has. A customer with 3 features gets a 3-item list; one with 7 gets a 7-item list — all from the same template.


{% raw %}
{% for item in array %}
{{ item }}
{% endfor %}
{% endraw %}
  • item is a temporary variable name — you choose it. It holds the current item on each pass through the loop.
  • array is the array to loop through. {% raw %}- Everything between {% for %} and {% endfor %} is repeated once per item.{% endraw %}
{% raw %}
{%- assign features = feature_list | split: "," -%}
Your active features:
{% for feature in features %}
- {{ feature | strip }}
{% endfor %}
{% endraw %}

If feature_list is "Analytics,Reporting,Alerts,Dashboards":

{% raw %}
Your active features:
- Analytics
- Reporting
- Alerts
- Dashboards
{% endraw %}

Inside a loop, Liquid provides a special object called forloop with information about the current iteration:

Variable What It Returns First Item Second Item Last Item (of 4)
forloop.index Position starting at 1 1 2 4
forloop.index0 Position starting at 0 0 1 3
forloop.first Is this the first item? true false false
forloop.last Is this the last item? false false true
forloop.length Total number of items 4 4 4
{% raw %}
{%- assign features = feature_list | split: "," -%}
Your top features this quarter:
{% for feature in features %}
{{ forloop.index }}. {{ feature | strip }}
{% endfor %}
{% endraw %}

Output:

{% raw %}
Your top features this quarter:
1. Analytics
2. Reporting
3. Alerts
4. Dashboards
{% endraw %}

Real Cast example — comma-separated with “and” before the last item

Section titled “Real Cast example — comma-separated with “and” before the last item”
{% raw %}
{%- assign features = feature_list | split: "," -%}
Your features include
{%- for feature in features -%}
{%- if forloop.last and forloop.length > 1 %} and {% elsif forloop.first == false %}, {% endif -%}
{{ feature | strip }}
{%- endfor -%}.
{% endraw %}

If there are 3 features: Your features include Analytics, Reporting and Alerts.

This uses forloop.last to insert “and” before the final item and forloop.first to avoid a leading comma.


The limit parameter stops the loop after a specified number of iterations. Useful when you want to show “top 3” or “first 5” from a longer list.

{% raw %}
{% for feature in features limit: 3 %}
{{ forloop.index }}. {{ feature | strip }}
{% endfor %}
{% endraw %}

Even if features has 10 items, only the first 3 are shown.

Real Cast example — top 3 with overflow count

Section titled “Real Cast example — top 3 with overflow count”
{% raw %}
{%- assign features = feature_list | split: "," -%}
Your top features this quarter:
{% for feature in features limit: 3 %}
{{ forloop.index }}. {{ feature | strip }}
{% endfor %}
...and {{ features.size | minus: 3 }} more.
{% endraw %}

Output (for 6 features):

{% raw %}
Your top features this quarter:
1. Analytics
2. Reporting
3. Alerts
...and 3 more.
{% endraw %}

The offset parameter skips a specified number of items from the beginning:

{% raw %}
{% for feature in features offset: 2 %}
{{ feature | strip }}
{% endfor %}
{% endraw %}

This skips the first 2 items and starts from the third.

You can use both together to get a “slice” of the array:

{% raw %}
{% for feature in features limit: 3 offset: 2 %}
{{ feature | strip }}
{% endfor %}
{% endraw %}

This skips 2 items, then shows the next 3 — items at positions 3, 4, and 5.


You can use if/else inside loops to customize each item’s display based on its value or position:

{% raw %}
{%- assign tasks = open_tasks | split: "," -%}
{% for task in tasks %}
{%- assign clean_task = task | strip -%}
🔴 Priority: {{ clean_task }}
{% else %}
⚪ {{ clean_task }}
{% endfor %}
{% endraw %}

Output:

{% raw %}
🔴 Priority: Renew contract
⚪ Schedule QBR
⚪ Update health score
{% endraw %}

What if the array is empty? You can handle this with an else clause directly inside the for block — this else runs only if the array has zero items:

{% raw %}
{%- assign features = feature_list | default: "" | split: "," -%}
{% for feature in features %}
{{ forloop.index }}. {{ feature | strip }}
{% else %}
No features are currently active on your account.
{% endfor %}
{% endraw %}

If feature_list is empty, the else content is shown instead of the loop body.

{% raw %}💡 This is cleaner than wrapping the whole thing in {% if features.size > 0 %}.{% endraw %}


You can put loops inside other loops, though this is less common in Cast narrations:

{% raw %}
{%- assign categories = "Features,Integrations" | split: "," -%}
{%- assign feature_items = "Analytics,Reporting" | split: "," -%}
{%- assign integration_items = "Salesforce,Slack,Jira" | split: "," -%}
{% for category in categories %}
{{ category }}:
{% for item in feature_items %}
- {{ item }}
{% endfor %}
{% else %}
{% for item in integration_items %}
- {{ item }}
{% endfor %}
{% endfor %}
{% endraw %}

⚠️ Keep nesting to one level when possible. Deeply nested loops are hard to read and maintain.


Loops can produce a lot of unwanted blank lines. Use whitespace control (-) to keep output clean:

Without whitespace control:

{% raw %}
{% for feature in features %}
{{ forloop.index }}. {{ feature | strip }}
{% endfor %}
{% endraw %}

Produces blank lines around each item.

With whitespace control:

{% raw %}
{%- for feature in features %}
{{ forloop.index }}. {{ feature | strip }}
{%- endfor %}
{% endraw %}

Cleaner output with no extra blank lines.

{% raw %}💡 Experiment with whitespace control placement to get the exact spacing you want. The - on {%- for %} strips the blank line before each item; the - on {%- endfor %} strips the blank line after the last item.{% endraw %}


Looping over a string instead of an array:

{% raw %}
{% for item in feature_list %}
{{ item }}
{% endfor %}
{% endraw %}

If feature_list is a comma-separated string (not an array), the loop iterates over individual characters.

Split first:

{% raw %}
{%- assign features = feature_list | split: "," -%}
{% for feature in features %}
{{ feature | strip }}
{% endfor %}
{% endraw %}

Forgetting endfor:

{% raw %}
{% for feature in features %}
{{ feature }}
{% endraw %}

Every for needs an endfor:

{% raw %}
{% for feature in features %}
{{ feature }}
{% endfor %}
{% endraw %}

Using the wrong variable name inside the loop:

{% raw %}
{% for feature in features %}
{{ item | strip }} ← should be "feature", not "item"
{% endfor %}
{% endraw %}

Use the variable name you declared in the for tag:

{% raw %}
{% for feature in features %}
{{ feature | strip }}
{% endfor %}
{% endraw %}

Exercise: You have open_tasks with the value "Renew contract,Schedule QBR,Update health score,Review usage data,Prepare expansion proposal". Write Liquid that:

  1. Splits it into an array
  2. Shows the total count of open tasks
  3. Lists the first 3 as a numbered list
  4. If there are more than 3, shows a message “…and X more tasks to address”
  5. If the list is empty, shows “No open tasks — great job!”
Click to reveal the answer
{% raw %}
{%- assign tasks = open_tasks | default: "" | split: "," -%}
You have {{ tasks.size }} open tasks:
{%- for task in tasks limit: 3 %}
{{ forloop.index }}. {{ task | strip }}
{%- endfor %}
...and {{ tasks.size | minus: 3 }} more tasks to address.
{% else %}
No open tasks — great job!
{% endraw %}

Output:

{% raw %}
You have 5 open tasks:
1. Renew contract
2. Schedule QBR
3. Update health score
...and 2 more tasks to address.
{% endraw %}

Key details:

  • default: "" handles a nil field (empty split produces an empty array)
  • tasks.size > 0 check wraps the entire loop for the empty case
  • limit: 3 restricts the numbered display
  • tasks.size | minus: 3 calculates the overflow count

In Module 15, you’ll get a deeper look at assign and capture in the context of building complex, reusable content — including patterns for assembling dynamic messages and the variable scoping rules you need to know.


📖 Official documentation: