Liquid tags

Tags are the parts of a template in {% %}: they loop, decide and hold values, and write nothing themselves. Values are written with {{ }}.

Selenic Orders uses its own Liquid engine, and it is not the same as Shopify’s. Most of what you know carries over. Some things do not, and the ones that matter most are listed in What this engine does not do. Every example on this page is rendered by the app’s own Preview Sample in a test, and the box under each one is exactly what it produces.

The examples use the sample order Preview Sample renders: order ORD-0001 for Kestrel Wine Bar, with two lines, WH001 and GN002. What every property holds is on Liquid objects.

Writing a value: {{ }}

Liquid
{% for order in orders %}{{ order.orderId }} for {{ order.customerCompanyNameSnapshot }}{% endfor %}
Text
ORD-0001 for Kestrel Wine Bar

A property that does not exist writes nothing and raises no error, so check spelling against Liquid objects when Preview shows a gap.

Loops: for

An order template gets a list called orders; a customer template gets customers. Everything inside {% for %} and {% endfor %} is written once per entry.

Liquid
{% for order in orders %}{% for item in order.lineItems %}{{ item.sku }} x {{ item.qty }}
{% endfor %}{% endfor %}
Text
WH001 x 6
GN002 x 6

Anything outside the loop is written once, which is how a CSV gets its header row. See Liquid recipes.

forloop

Inside a loop, forloop says where you are.

VariableGives
forloop.indexPosition, counting from 1
forloop.index0Position, counting from 0
forloop.rindexPlaces left, counting this one
forloop.rindex0Places left, not counting this one
forloop.firsttrue on the first time round
forloop.lasttrue on the last time round
forloop.lengthHow many entries the list has
forloop.parentloopThe forloop of the loop around this one

forloop.counter, counter0, revcounter and revcounter0 are other names for index, index0, rindex and rindex0.

Liquid
{% for order in orders %}{% for item in order.lineItems %}{{ forloop.index }} of {{ forloop.length }}: {{ item.sku }} first={{ forloop.first }} last={{ forloop.last }}
{% endfor %}{% endfor %}
Text
1 of 2: WH001 first=true last=false
2 of 2: GN002 first=false last=true
Liquid
{% for order in orders %}{% for item in order.lineItems %}{{ forloop.index0 }} {{ forloop.rindex }} {{ forloop.rindex0 }}
{% endfor %}{% endfor %}
Text
0 2 1
1 1 0

forloop.parentloop numbers lines within orders:

Liquid
{% for order in orders %}{% for item in order.lineItems %}{{ forloop.parentloop.index }}.{{ forloop.index }} {{ item.sku }}
{% endfor %}{% endfor %}
Text
1.1 WH001
1.2 GN002

Separators between entries

A JSON list or an XML attribute list breaks on a separator after the last entry. Write the separator unless this is the last time round:

Liquid
[{% for order in orders %}{% for item in order.lineItems %}"{{ item.sku }}"{% unless forloop.last %},{% endunless %}{% endfor %}{% endfor %}]
Text
["WH001","GN002"]

forloop.last works in every loop. The isLast and isLastOrder properties on lines and orders do the same job; a customer’s addresses have no isLast in a real export, so use forloop.last there.

When the list is empty: else

{% else %} inside a loop is written instead when the list is empty or missing. {% empty %} means the same.

Liquid
{% for order in orders %}{% for line in order.notAList %}{{ line.sku }}{% else %}No lines{% endfor %}{% endfor %}
Text
No lines

Conditions: if, elseif, else

It is elseif, not elsif. Shopify’s spelling is an error here.

Liquid
{% for order in orders %}{% if order.grandTotal > 500 %}LARGE{% elseif order.grandTotal > 100 %}MEDIUM{% else %}SMALL{% endif %}{% endfor %}
Text
MEDIUM

Comparing

OperatorMeans
==Equal
!= or <>Not equal
<, >, <=, >=Less, greater, or equal to either
Liquid
{% for order in orders %}{% if order.currencyCode == "GBP" %}Sterling{% endif %}{% if order.currencyCode != "EUR" %}, not euro{% endif %}{% endfor %}
Text
Sterling, not euro

A number is not the same as the text of a number. Quantities and money are numbers, so compare them without quotes:

Liquid
{% for order in orders %}{% for item in order.lineItems %}{% if item.qty == 6 %}number {% endif %}{% if item.qty == "6" %}text {% endif %}{% endfor %}{% endfor %}
Text
number number

< and > on a property that does not exist is an error, not false. Check the spelling first.

True, false and empty

A property on its own is true when it has something in it. Empty text, 0, an empty list and a missing property are all false.

Liquid
{% for order in orders %}{% if order.appliedPromotionCode %}Promotion {{ order.appliedPromotionCode }}{% endif %}{% if order.carriageWaivedBy %}, free delivery{% endif %}{% endfor %}
Text
Promotion SUMMER10

not turns a condition round:

Liquid
{% for order in orders %}{% if not order.carriageWaivedBy %}Delivery charged{% endif %}{% endfor %}
Text
Delivery charged

Two conditions at once

and and or do not work in this engine. and is always false and or only ever checks its left-hand side, so Preview and Save refuse a template that uses either. Put one if inside another instead of and:

Liquid
{% for order in orders %}{% if order.currencyCode == "GBP" %}{% if order.grandTotal > 100 %}Sterling, over 100{% endif %}{% endif %}{% endfor %}
Text
Sterling, over 100

and use elseif with the same result instead of or:

Liquid
{% for order in orders %}{% if order.currencyCode == "GBP" %}HOME{% elseif order.currencyCode == "EUR" %}HOME{% else %}FOREIGN{% endif %}{% endfor %}
Text
HOME

unless

The opposite of if: written when the condition is false. It can have an else.

Liquid
{% for order in orders %}{% unless order.requestedDate == "" %}Wanted by {{ order.requestedDate }}{% endunless %}{% endfor %}
Text
Wanted by 2026-08-16 00:00:00
Liquid
{% for order in orders %}{% unless order.carriageWaivedBy %}Charged{% else %}Waived{% endunless %}{% endfor %}
Text
Charged

Choosing between several values

There is no case tag. Write the choices as if and elseif:

Liquid
{% for order in orders %}{% if order.carriageCode == "NEXT" %}24HR{% elseif order.carriageCode == "PALLET" %}PLT{% else %}STD{% endif %}{% endfor %}
Text
STD

assign

Holds a value under a name of your own. It can take filters.

Liquid
{% assign separator = ";" %}{% for order in orders %}{{ order.orderId }}{{ separator }}{{ order.customerId }}{% endfor %}
Text
ORD-0001;ACC-1042
Liquid
{% assign account = orders.0.customerId | downcase %}{{ account }}
Text
acc-1042

Inside a loop or an if, assign needs a closing {% endassign %}, and the name only exists until then. Without it, Preview reports an unexpected endfor.

Liquid
{% for order in orders %}{% assign ship = order.shippingAddressSnapshot %}{{ ship.city }} {{ ship.postcode }}{% endassign %}{% endfor %}
Text
Anytown AB12 3CD

An assign at the top of the template, outside every block, needs no endassign: it lasts to the end.

capture

Writes everything between {% capture %} and {% endcapture %} into a name instead of the output, as text.

Liquid
{% for order in orders %}{% capture ref %}{{ order.orderId }}/{{ order.customerId }}{% endcapture %}{{ ref }}{% endfor %}
Text
ORD-0001/ACC-1042

A capture made inside a loop does not survive the loop. There is no way to carry a value from one time round to the next, or out of the loop:

Liquid
{% capture last %}none{% endcapture %}{% for order in orders %}{% capture last %}{{ order.orderId }}{% endcapture %}{% endfor %}{{ last }}
Text
none

Comments

Liquid
OrderId,Total{% comment %}Sage wants exactly these two headers{% endcomment %}
Text
OrderId,Total

The short form, {% # note %}, is not supported.

Whitespace control

Every tag keeps the line breaks and spaces around it, so a loop laid out over several lines writes blank lines:

Liquid
Orders:
{% for order in orders %}
  {{ order.orderId }}
{% endfor %}
End
Text
Orders:

  ORD-0001

End

A hyphen inside the brace, {%- or {{-, removes the spaces and line breaks before the tag. -%} or -}} removes them after.

Liquid
Orders:
{%- for order in orders %}
  {{ order.orderId }}
{%- endfor %}
End
Text
Orders:
  ORD-0001
End
Liquid
Order:
{%- for order in orders -%}
  {{- order.orderId -}}
{%- endfor %}
Text
Order:ORD-0001

Spaces and line breaks at the very start and end of the whole template are removed before it runs.

Also available

These come from the engine’s Django side rather than Shopify’s, and are supported.

cycle writes the next of its values each time it is reached. It keeps counting across the whole file, not per order.

Liquid
{% for order in orders %}{% for item in order.lineItems %}{% cycle "A", "B" %}:{{ item.sku }}
{% endfor %}{% endfor %}
Text
A:WH001
B:GN002

ifchanged writes its contents only when they differ from the last time:

Liquid
{% for order in orders %}{% for item in order.lineItems %}{% ifchanged %}{{ item.price_label }}
{% endifchanged %}{% endfor %}{% endfor %}
Text
Trade

filter applies a filter to everything inside it:

Liquid
{% filter upcase %}{% for order in orders %}{{ order.customerCompanyNameSnapshot }}{% endfor %}{% endfilter %}
Text
KESTREL WINE BAR

regroup groups a list by one property, into a list of groups each with a grouper and a list:

Liquid
{% for order in orders %}{% regroup order.lineItems by price_label to tiers %}{% for tier in tiers %}{{ tier.grouper }}: {{ tier.list | size }} lines{% endfor %}{% endfor %}
Text
Trade: 2 lines

What this engine does not do

Each of these is checked against the app, so the list stays true.

Reported as an error in Preview Sample:

Not supportedInstead
case and whenif and elseif
elsifelseif
rawNo equivalent
include and renderEverything in one template
{% # note %}{% comment %}
Ranges such as (1..3)No equivalent
Square brackets, lineItems[0]lineItems.0
.first, .last, .size on a list.0 for the first; the size filter
assign inside a block without endassignClose it with {% endassign %}
< or > against a missing propertyCheck the spelling
A quote mark inside quotes of the same kindUse the other kind: '12" tray'

What this engine gets wrong. Preview and Save refuse the first three and say what to write instead. The rest are accepted without complaint, and Preview shows output that looks plausible:

WrittenWhat actually happens
andAlways false
orOnly the left-hand side is checked
containsAlways false, on text and on a list
limit:, offset:, reversed on a loopIgnored: every entry, in order. The slice filter picks entries instead
A list position past the end, such as lineItems.2 on a two-line orderWraps round to the start
Liquid
{% for order in orders %}{% if order.currencyCode == "GBP" and order.grandTotal > 100 %}Y{% else %}N{% endif %}{% endfor %}

This writes N, though both are true of the sample order.