Customer.io Liquid and/or: why your conditions evaluate right to left
On the evening of 22 January 1980, Willie Ramirez, an 18-year-old baseball player, was taken by ambulance to a South Florida hospital in a coma. His family, who spoke Spanish, thought something he had eaten had made him ill. Among Cubans, intoxicado means something is wrong with you because of something you ate or drank (Health Affairs). The word reached the emergency room doctor as intoxicated (NPR).
The doctors treated him for an intentional drug overdose, and no neurological consultation was ordered for two days. He was bleeding inside his brain. By the time it was found, surgery came too late, and he was left quadriplegic. Neither side asked for a professional interpreter, "because each side believed they were communicating adequately" (Health Affairs).
One familiar-looking word, read under a different rule, meant something else entirely. Liquid's and and or fail the same quiet way, at far smaller stakes. They look like the words you use in JavaScript or plain English. Liquid reads them by its own rule, and nothing on screen tells you.
TL;DR: Liquid has no brackets and checks and and or from right to left, so a and b or c means a and (b or c). That matches neither reading most people assume. Read and-first, as in JavaScript, where && binds tighter than ||, it is (a and b) or c; read left to right, you get the same. The two readings disagree in two of the eight possible cases. Parentheses do not help: Shopify's docs call them invalid characters that stop the tag working. Customer.io never documents its own order of operations, but every engine its docs point to evaluates right to left. So treat any condition that mixes and and or as a bug until proven otherwise. Rewrite it as nested if blocks, assign a flag first, compute a boolean upstream, or move the targeting into a segment, where Customer.io gives you real groups.
Liquid checks and/or from right to left
Shopify's Liquid reference puts the rule in one line: in tags with more than one and or or, operators are checked "in order from right to left" (Shopify Liquid, operators).
Its own worked example shows what that means. This condition is false:
{% if true and false and false or true %}
This never renders.
{% endif %}
Read with the usual rule, and first, it would be (true and false and false) or true, which is true. Liquid instead nests from the right: true and (false and (false or true)), then true and (false and true), then true and false, then false. The page walks through those same steps.
The same page shows a second example, true or false and false, which is true "since the and condition is checked first". That is the reassuring case. With the or on the left, right-to-left grouping and the and-first convention happen to agree. The trouble starts when an and sits to the left of an or.
If you are new to conditions in Customer.io, start with our Liquid tutorial for personalisation without code, then come back here.
Which rule does Customer.io's Liquid follow?
Customer.io's docs never say. Its Liquid syntax list defines and and or one at a time, and says nothing about mixing them (Customer.io, Liquid syntax list). So the honest answer comes from what the docs point to.
Customer.io runs two Liquid versions. Accounts created on or after 28 November 2023 use the latest version for every message. Older accounts may still have legacy messages, and can upgrade or downgrade message by message (Customer.io, Liquid upgrade). You can check a message's version by hovering over its last saved date in the editor.
Both point at right-to-left engines:
- The
ifreference. In both versions of the tag list, theifentry links to the wiki ofcustomerio/liquid. That is Customer.io's own repository on GitHub, branched from Shopify's Ruby Liquid (GitHub, customerio/liquid). Itsiftag reverses the list of conditions before combining them, which is right-to-left grouping in code (GitHub, if.rb). - The latest version. Several latest-version entries, including
default,foranddate, link to LiquidJS documentation. LiquidJS says logical operators "are evaluated from right to left", and refers to Shopify's docs for the detail (LiquidJS, operators).
We have not seen Customer.io confirm the rule in writing, so do not take our word for it either. Paste this into any message and preview it:
{% assign a = false %}
{% assign b = true %}
{% assign c = true %}
{% if a and b or c %}and first, or left to right{% else %}right to left{% endif %}
If the preview says right to left, your message's engine reads a and (b or c). Run it once in a legacy message and once in a latest one if your workspace has both.
The truth tables: where right to left bites
Two tables cover it. We worked them by hand from the grouping rules above; they are logic, not measurements. Each row is one combination of three conditions, so each table has eight rows.
a and b or c: Liquid groups this as a and (b or c). JavaScript's and-first rule and a left-to-right reading both give (a and b) or c.
| a | b | c | Liquid: a and (b or c) | Usual reading: (a and b) or c |
|---|---|---|---|---|
| true | true | true | true | true |
| true | true | false | true | true |
| true | false | true | true | true |
| true | false | false | false | false |
| false | true | true | false | true |
| false | true | false | false | false |
| false | false | true | false | true |
| false | false | false | false | false |
The two readings disagree whenever a is false and c is true. That is two of the eight cases, and in both Liquid says no where you expected yes.
a or b and c: Liquid groups this as a or (b and c), which is also what the and-first convention gives. A strict left-to-right reading gives (a or b) and c.
| a | b | c | Liquid and and-first: a or (b and c) | Left to right: (a or b) and c |
|---|---|---|---|---|
| true | true | true | true | true |
| true | true | false | true | false |
| true | false | true | true | true |
| true | false | false | true | false |
| false | true | true | true | true |
| false | true | false | false | false |
| false | false | true | false | false |
| false | false | false | false | false |
So a or b and c is safe if you think like a programmer, and wrong if you read it like a sentence. a and b or c is wrong under both habits.
Longer chains compound it. a or b and c or d becomes a or (b and (c or d)) in Liquid. With a and b false and d true, Liquid says false, while the and-first reading, a or (b and c) or d, says true.
A condition that breaks in a real email
Say a promotion should show to Pro customers in the UK, plus every VIP wherever they are. Most people write it the way they would say it:
{% if customer.plan == "pro" and customer.country == "GB" or customer.tier == "vip" %}
Your exclusive offer is inside.
{% endif %}
Liquid reads that as Pro, and also either in the UK or a VIP. A VIP on the free plan in Germany never sees the offer: customer.plan == "pro" is false, and everything to its right is grouped under it. Nobody gets an error. The block stays empty for the people it was meant for, and the preview only shows it if you pick the right test profile.
That silent failure is the reason we treat every mixed condition as suspect. A syntax error gets noticed. A grouping mistake ships.
Why brackets will not save you
The natural fix is brackets, and Liquid does not have them. You cannot change the order of operations with parentheses, says Shopify's operators page. They "are invalid characters in Liquid and will prevent your tags from working" (Shopify Liquid, operators). LiquidJS's operators page documents precedence and associativity and offers no grouping syntax either (LiquidJS, operators).
LiquidJS does document a not operator. Customer.io's syntax list does not, in either version (Customer.io, Liquid syntax list). Write != or unless instead of relying on it.
The syntax list documents and and or only, so treat && and || as unsupported in templates.
The other traps inside a mixed condition
Grouping is the headline problem. Smaller rules can each flip a single clause, and the grouping then carries that flip somewhere you did not expect.
Empty strings are true
In Liquid, only nil and false are falsy. Every string is truthy, "even when empty", and so is 0 (Shopify Liquid, truthy and falsy). So customer.first_name and customer.plan == "pro" passes for a Pro customer whose first name was saved as an empty string.
nil, blank and empty
Customer.io's upgrade page sets out the checks. Comparing to blank catches an attribute that is not stored, null, false or an empty string. Compare to nil instead when false is a valid value you want to keep (Customer.io, Liquid upgrade). The latest version adds empty for arrays:
{% if customer.purchases == empty %}
Ready to make your first purchase?
{% endif %}
The same page warns that a missing number falls through to else. With {% if customer.order_count < 10 %}, people with no order_count at all get the else branch, so check the attribute exists first. Inside a mixed condition, that missing value quietly turns one clause false, and right-to-left grouping decides how far the damage spreads.
Numbers stored as strings
Customer.io's recipes page says attributes "are stored as strings", even when the value is a number. Convert before comparing, by adding zero with the plus filter (Customer.io, Liquid recipes):
{% assign order_count = customer.order_count | plus: 0 %}
{% if order_count > 5 %}
Thanks for being a regular.
{% endif %}
Do the conversion in an assign before the condition. It keeps the if line short, so a reviewer reads one comparison, not three.
contains groups the way it reads
In LiquidJS, contains shares the highest precedence with the comparison operators, so customer.tags contains 'vip' or customer.plan == 'pro' groups the way it reads (LiquidJS, operators). Its one limit has nothing to do with mixing: it searches strings and arrays of strings, never an array of objects (Customer.io, Liquid syntax list).
unless flips the whole condition
unless is the opposite of if: the block runs when the condition is not met (Shopify Liquid, control flow). With a mixed condition, it negates the right-to-left grouping as a whole. unless a and b or c runs when a and (b or c) is false. Keep unless to a single comparison.
case and when replace or-chains
When every or tests the same attribute, use case. Customer.io's syntax list shows a when that accepts two values joined by or, which is the form used here (Customer.io, Liquid syntax list). Shopify's docs also allow a comma-separated list (Shopify Liquid, control flow). Customer.io does not show that form, so stick to or:
{% case customer.plan %}
{% when "pro" or "business" %}
Your plan includes priority support.
{% when "free" %}
Upgrade for priority support.
{% else %}
Talk to us about support options.
{% endcase %}
No grouping question can arise, because there is nothing to group.
Our Liquid anti-patterns guide covers the wider set of habits that break Customer.io templates; this post stays on how conditions combine.
Four rewrites that cannot be misread
Each removes the mixed condition instead of relying on memory.
1. Nest the ifs
Put the outer and in one tag and the inner logic in another. Each tag then uses one operator, so the grouping is visible in the indentation:
{% if customer.country == "GB" %}
{% if customer.plan == "pro" or customer.plan == "business" %}
Your UK offer is inside.
{% endif %}
{% endif %}
This works when the logic is A and (B or C). It gets clumsy for (A and B) or C, because the or branch has to repeat the content. Use a flag for that shape.
2. Assign a flag first
Work out the answer in separate, single-operator steps, store it in a variable, then test the variable once:
{% assign show_offer = false %}
{% if customer.plan == "pro" and customer.country == "GB" %}
{% assign show_offer = true %}
{% endif %}
{% if customer.tier == "vip" %}
{% assign show_offer = true %}
{% endif %}
{% if show_offer %}
Your exclusive offer is inside.
{% endif %}
That is the broken example from earlier, fixed. Each if has one operator at most, the or lives in the fact that either block can set the flag, and the final test cannot be misread. A reviewer can check each line on its own. Customer.io's own version fallback uses the same pattern, assigning a variable and then testing it with unless (Customer.io, Liquid upgrade).
The right-to-left rule does allow a one-line version: put the or clause first. customer.tier == 'vip' or customer.plan == 'pro' and customer.country == 'GB' groups as VIP, or (Pro and UK), which is the intended logic. It is correct, and it is fragile. Its meaning depends on clause order, and the next person to add a condition at the end changes the grouping without any warning. The flag survives that edit; the one-liner does not.
3. Compute the boolean upstream
If the same test appears in several messages, stop writing it in Liquid. Compute it once and store the answer.
You can do that in your app, before the data reaches Customer.io. Inside an automation, two actions accept JavaScript: Create or Update Person, which writes the value to the profile, and Create Event, which puts it in an event's data. The code runs on the V8 engine and returns a value from the trigger data (Customer.io, JavaScript in actions). JavaScript has real precedence and real brackets: logical AND binds tighter than logical OR (MDN, operator precedence). This returns exactly what it says.
return (customer.plan === "pro" && customer.country === "GB") || customer.tier === "vip";
A profile attribute fits when the value describes the customer and other messages will use it. Our attributes vs events rule helps you decide whether it also needs an event. If the answer only matters inside one automation, a journey attribute may be the better home. Those are set separately, not by the two JavaScript actions above; our journey attributes guide covers how and when.
One caution, given the recipes page's note that attributes are stored as strings. Test a stored flag against both forms, customer.show_offer == true or customer.show_offer == 'true', or store a label such as uk_pro and compare to that. A single or like this is safe; it is the mix that causes trouble.
4. Move the targeting into a segment
Most mixed conditions are audience rules dressed up as content. Pro customers in the UK, or any VIP is a question about who gets the message, not what it says.
Customer.io's data-driven segments have the grouping Liquid lacks. You choose All (and) or At least one (or), then nest conditions with Group to build logic like condition1 AND (condition2 OR condition3) (Customer.io, data-driven segments). The is one of operator replaces a run of or conditions on one attribute, up to 50 values per condition.
Build the segment, send the message to it, and the template needs no condition at all. Our advanced segmentation guide goes further on groups and nested logic.
Which rewrite to use
| Situation | Use | Why |
|---|---|---|
Only and, or only or, in one tag |
Leave it | Grouping cannot change the answer |
Every or tests one attribute |
case / when |
No operators to group |
| Logic decides who receives the message | Segment with Groups | Real grouping, and the template stays clean |
| A and (B or C), content varies inside one message | Nested ifs | Indentation shows the grouping |
| (A and B) or C, or more than two levels deep | Assign a flag | Single-operator steps, one final test |
| Same test in three or more messages | Compute upstream | One definition, tested once |
The three-message threshold is our rule of thumb, not a Customer.io limit.
When to leave a condition alone
Not every condition needs rewriting. A tag that uses only and, or only or, cannot be misgrouped, so leave it. Shopify's first and/or example, product.type == "Shirt" or product.type == "Shoes", is fine as written.
An a or b and c condition that has been reviewed and gives the right answer is also correct as it stands, because right-to-left grouping matches the and-first reading for that shape. Still add a comment saying so, because the next person to touch it may add a clause and change the shape.
Do not create a profile attribute for a one-off message either. Attributes outlive campaigns; a flag assigned inside the template does the same job and disappears with it.
If you want your team writing conditions like these without a code review on every email, our Customer.io and email marketing training covers Liquid logic alongside segments and automations. For a broader walkthrough first, see our practical Liquid tutorial for marketers.
Frequently asked questions
Does Liquid evaluate and/or from left to right or right to left?
Liquid evaluates and and or from right to left. Shopify's Liquid reference says operators in a tag with more than one and or or are checked from right to left, so true and false and false or true evaluates to false (Shopify Liquid, operators).
Can I use parentheses in Liquid if statements?
No, Liquid has no parentheses for grouping conditions. Shopify's docs call them invalid characters that stop the tag working, so use nested if blocks or assign a flag variable instead (Shopify Liquid, operators).
Does Customer.io's Liquid use the same order of operations as Shopify?
Customer.io does not document an order of operations, but every engine its docs point to evaluates right to left. Its if reference links to Customer.io's fork of Shopify's Ruby Liquid, and latest-version entries link to LiquidJS, which documents right-to-left evaluation (LiquidJS, operators). Test it with a short preview template to be certain for your workspace.
Why does my Customer.io Liquid condition with and and or not work?
The most likely cause is that Liquid grouped it from the right. a and b or c means a and (b or c), so a person who matches only c fails the whole test. Split the logic into nested if blocks or assign a flag first.
How do I write (A and B) or C in Liquid?
The safest way is to assign a flag in two separate steps, then test the flag. Set it to true in one if that checks A and B, set it again in a second if that checks C, and wrap your content in a single if on the flag. Liquid's right-to-left rule also lets you write C or A and B, which groups as C or (A and B). That works, but a clause added later can silently change the grouping, so the flag is the version that survives edits.
Is an empty string true or false in Customer.io Liquid?
An empty string is true in Liquid, because only nil and false are falsy (Shopify Liquid, truthy and falsy). Compare to blank instead, which Customer.io says catches an attribute that is missing, null, false or an empty string (Customer.io, Liquid upgrade).
Should I use a segment or Liquid for complex conditions in Customer.io?
Use a segment when the condition decides who receives the message, and Liquid only when it changes content inside one message. Customer.io segments support nested groups for logic like condition1 AND (condition2 OR condition3) (Customer.io, data-driven segments). Liquid reaches that grouping only through its right-to-left rule, with no brackets to show the next reader which grouping you meant.
Sources
- Shopify, Operators, Liquid template language, read 12 October 2026.
- Shopify, Truthy and falsy, Liquid template language, read 12 October 2026.
- Shopify, Control flow, Liquid template language, read 12 October 2026.
- LiquidJS, Operators, read 12 October 2026.
- Customer.io, Liquid syntax list, updated 8 October 2026.
- Customer.io, Liquid upgrade, updated 14 September 2026.
- Customer.io, Liquid recipes, updated 26 September 2026.
- Customer.io, Data-driven segments, updated 8 October 2026.
- Customer.io, JavaScript in actions, updated 14 September 2026.
- GitHub, customerio/liquid and its if.rb, read 12 October 2026.
- MDN Web Docs, Operator precedence, read 12 October 2026.
- Gail Price-Wise, Language, Culture, And Medical Tragedy: The Case Of Willie Ramirez, Health Affairs Forefront, 19 November 2008.
- NPR, In The Hospital, A Bad Translation Can Destroy A Life, Shots, 27 October 2014.
Want this working in your Customer.io workspace?
It's what we do all day for SMBs—strategy, automations, deliverability and hands-on execution.
See how we work as a Customer.io agency →
