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: {{ }}
{% for order in orders %}{{ order.orderId }} for {{ order.customerCompanyNameSnapshot }}{% endfor %}ORD-0001 for Kestrel Wine BarA 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.
{% for order in orders %}{% for item in order.lineItems %}{{ item.sku }} x {{ item.qty }}
{% endfor %}{% endfor %}WH001 x 6
GN002 x 6Anything 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.
| Variable | Gives |
|---|---|
forloop.index | Position, counting from 1 |
forloop.index0 | Position, counting from 0 |
forloop.rindex | Places left, counting this one |
forloop.rindex0 | Places left, not counting this one |
forloop.first | true on the first time round |
forloop.last | true on the last time round |
forloop.length | How many entries the list has |
forloop.parentloop | The forloop of the loop around this one |
forloop.counter, counter0, revcounter and revcounter0 are other names for index, index0, rindex and rindex0.
{% for order in orders %}{% for item in order.lineItems %}{{ forloop.index }} of {{ forloop.length }}: {{ item.sku }} first={{ forloop.first }} last={{ forloop.last }}
{% endfor %}{% endfor %}1 of 2: WH001 first=true last=false
2 of 2: GN002 first=false last=true{% for order in orders %}{% for item in order.lineItems %}{{ forloop.index0 }} {{ forloop.rindex }} {{ forloop.rindex0 }}
{% endfor %}{% endfor %}0 2 1
1 1 0forloop.parentloop numbers lines within orders:
{% for order in orders %}{% for item in order.lineItems %}{{ forloop.parentloop.index }}.{{ forloop.index }} {{ item.sku }}
{% endfor %}{% endfor %}1.1 WH001
1.2 GN002Separators 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:
[{% for order in orders %}{% for item in order.lineItems %}"{{ item.sku }}"{% unless forloop.last %},{% endunless %}{% endfor %}{% endfor %}]["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.
{% for order in orders %}{% for line in order.notAList %}{{ line.sku }}{% else %}No lines{% endfor %}{% endfor %}No linesConditions: if, elseif, else
It is elseif, not elsif. Shopify’s spelling is an error here.
{% for order in orders %}{% if order.grandTotal > 500 %}LARGE{% elseif order.grandTotal > 100 %}MEDIUM{% else %}SMALL{% endif %}{% endfor %}MEDIUMComparing
| Operator | Means |
|---|---|
== | Equal |
!= or <> | Not equal |
<, >, <=, >= | Less, greater, or equal to either |
{% for order in orders %}{% if order.currencyCode == "GBP" %}Sterling{% endif %}{% if order.currencyCode != "EUR" %}, not euro{% endif %}{% endfor %}Sterling, not euroA number is not the same as the text of a number. Quantities and money are numbers, so compare them without quotes:
{% for order in orders %}{% for item in order.lineItems %}{% if item.qty == 6 %}number {% endif %}{% if item.qty == "6" %}text {% endif %}{% endfor %}{% endfor %}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.
{% for order in orders %}{% if order.appliedPromotionCode %}Promotion {{ order.appliedPromotionCode }}{% endif %}{% if order.carriageWaivedBy %}, free delivery{% endif %}{% endfor %}Promotion SUMMER10not turns a condition round:
{% for order in orders %}{% if not order.carriageWaivedBy %}Delivery charged{% endif %}{% endfor %}Delivery chargedTwo 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:
{% for order in orders %}{% if order.currencyCode == "GBP" %}{% if order.grandTotal > 100 %}Sterling, over 100{% endif %}{% endif %}{% endfor %}Sterling, over 100and use elseif with the same result instead of or:
{% for order in orders %}{% if order.currencyCode == "GBP" %}HOME{% elseif order.currencyCode == "EUR" %}HOME{% else %}FOREIGN{% endif %}{% endfor %}HOMEunless
The opposite of if: written when the condition is false. It can have an else.
{% for order in orders %}{% unless order.requestedDate == "" %}Wanted by {{ order.requestedDate }}{% endunless %}{% endfor %}Wanted by 2026-08-16 00:00:00{% for order in orders %}{% unless order.carriageWaivedBy %}Charged{% else %}Waived{% endunless %}{% endfor %}ChargedChoosing between several values
There is no case tag. Write the choices as if and elseif:
{% for order in orders %}{% if order.carriageCode == "NEXT" %}24HR{% elseif order.carriageCode == "PALLET" %}PLT{% else %}STD{% endif %}{% endfor %}STDassign
Holds a value under a name of your own. It can take filters.
{% assign separator = ";" %}{% for order in orders %}{{ order.orderId }}{{ separator }}{{ order.customerId }}{% endfor %}ORD-0001;ACC-1042{% assign account = orders.0.customerId | downcase %}{{ account }}acc-1042Inside a loop or an if, assign needs a closing {% endassign %}, and the name only exists until then. Without it, Preview reports an unexpected endfor.
{% for order in orders %}{% assign ship = order.shippingAddressSnapshot %}{{ ship.city }} {{ ship.postcode }}{% endassign %}{% endfor %}Anytown AB12 3CDAn 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.
{% for order in orders %}{% capture ref %}{{ order.orderId }}/{{ order.customerId }}{% endcapture %}{{ ref }}{% endfor %}ORD-0001/ACC-1042A 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:
{% capture last %}none{% endcapture %}{% for order in orders %}{% capture last %}{{ order.orderId }}{% endcapture %}{% endfor %}{{ last }}noneComments
OrderId,Total{% comment %}Sage wants exactly these two headers{% endcomment %}OrderId,TotalThe 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:
Orders:
{% for order in orders %}
{{ order.orderId }}
{% endfor %}
EndOrders:
ORD-0001
EndA hyphen inside the brace, {%- or {{-, removes the spaces and line breaks before the tag. -%} or -}} removes them after.
Orders:
{%- for order in orders %}
{{ order.orderId }}
{%- endfor %}
EndOrders:
ORD-0001
EndOrder:
{%- for order in orders -%}
{{- order.orderId -}}
{%- endfor %}Order:ORD-0001Spaces 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.
{% for order in orders %}{% for item in order.lineItems %}{% cycle "A", "B" %}:{{ item.sku }}
{% endfor %}{% endfor %}A:WH001
B:GN002ifchanged writes its contents only when they differ from the last time:
{% for order in orders %}{% for item in order.lineItems %}{% ifchanged %}{{ item.price_label }}
{% endifchanged %}{% endfor %}{% endfor %}Tradefilter applies a filter to everything inside it:
{% filter upcase %}{% for order in orders %}{{ order.customerCompanyNameSnapshot }}{% endfor %}{% endfilter %}KESTREL WINE BARregroup groups a list by one property, into a list of groups each with a grouper and a list:
{% for order in orders %}{% regroup order.lineItems by price_label to tiers %}{% for tier in tiers %}{{ tier.grouper }}: {{ tier.list | size }} lines{% endfor %}{% endfor %}Trade: 2 linesWhat 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 supported | Instead |
|---|---|
case and when | if and elseif |
elsif | elseif |
raw | No equivalent |
include and render | Everything 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 endassign | Close it with {% endassign %} |
< or > against a missing property | Check the spelling |
| A quote mark inside quotes of the same kind | Use 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:
| Written | What actually happens |
|---|---|
and | Always false |
or | Only the left-hand side is checked |
contains | Always false, on text and on a list |
limit:, offset:, reversed on a loop | Ignored: every entry, in order. The slice filter picks entries instead |
A list position past the end, such as lineItems.2 on a two-line order | Wraps round to the start |
{% 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.