I shipped a sale-badge snippet on a Factory Direct Blinds collection page in March 2026 and the badge flickered onto unavailable products. Three lines of if. One mixed and / or. Liquid evaluated it right to left and produced the opposite of what every JavaScript dev on the team expected.
TL;DR: Liquid evaluates and and or strictly right to left with no precedence between them. JavaScript, C, and Ruby all bind and tighter than or. Liquid does not. The reliable fix is to assign each compound condition to a named boolean before the if block, or to nest a second if. This applies inside snippets, sections, and Section Rendering API responses. Parentheses do not work in Liquid.
Why this matters for your store
- A single mixed
and/oron a sale-badge snippet can flag every unavailable product as on sale. That is a refund-rate problem, not a styling bug. - Discount-eligibility logic written by a JS-trained contractor silently misroutes the cheaper of two coupons whenever availability and stack-ability flip relative order. The customer sees the higher price; you eat the support ticket.
- Section Rendering API responses cache by URL plus query string. If your
ifreturns the wrong branch, every cached fragment stays wrong until the cache rotates, which can be hours on Plus stores under flash-sale traffic.
How does Liquid evaluate and and or?
Liquid evaluates and and or from right to left. There is no precedence between them. The same operator order you trust everywhere else in programming, where and binds tighter than or, does not exist in Liquid. The Shopify Liquid reference calls this out, and Theme Check does not warn you when you stack the two on one line.
Take this real example from a 2026 sale-badge build:
{# snippets/sale-badge.liquid #}
{% if product.available and product.tags contains 'sale' or product.compare_at_price > product.price %}
<span class="sale-badge">On Sale</span>
{% endif %}
A JavaScript developer reads this as (available AND has-sale-tag) OR has-compare-at. Liquid reads it right to left as available AND (has-sale-tag OR has-compare-at). The two outcomes diverge whenever the product is unavailable but has a compare-at price, which is most of an end-of-season catalogue.
End result: the badge fires on every sold-out product because the parser short-circuits incorrectly. The merchant assumes the dev is incompetent. The dev assumes Liquid is broken. Both lose an afternoon to a one-line bug that Theme Check never flags.
The same rule trips B2B catalog gating, free-sample eligibility, and quantity-discount tiers. Anywhere your snippet mixes availability with a tag check or a metafield check on a single line, you are running on Liquid’s right-to-left order, not the order your codebase assumes elsewhere.
The named-boolean fix that ships in production
Push the boolean math into assign statements before the if block. When the if only sees a single named flag, the parser has nothing to reorder.
{# snippets/sale-badge.liquid #}
{%- assign has_sale_tag = product.tags contains 'sale' -%}
{%- assign has_compare_price = product.compare_at_price > product.price -%}
{%- assign show_badge = false -%}
{%- if product.available -%}
{%- if has_sale_tag or has_compare_price -%}
{%- assign show_badge = true -%}
{%- endif -%}
{%- endif -%}
{%- if show_badge -%}
<span class="sale-badge">On Sale</span>
{%- endif -%}
Three things happen here. The intermediate booleans get named so a future reader sees the intent before the logic. The compound condition becomes a nested if, which Liquid evaluates predictably. The output stage is a single boolean check, which makes the snippet trivially testable.
For a Liquid-loop-heavy version of the same pattern (where this matters most under iteration), see the Liquid loop optimization guide. For the broader picture of where snippets fit, the render tag named parameters reference covers the other half of the safe-snippet pattern.
The same trap inside unless and case / when
unless is just if not, but its negation amplifies the right-to-left bug. unless product.available and product.compare_at_price > product.price or product.tags contains 'gift' reads to a JavaScript dev as “show unless (available AND has-discount) OR is a gift.” Liquid reads it as “show unless available AND (has-discount OR is a gift).” A gift product with no discount stops rendering. A discounted gift renders. The merchant cannot replicate the bug because their test product happens to be both.
case / when is safer because each when branch is a single equality check. The trap is when devs assemble the case value with a one-line conditional concatenation, which then carries the same precedence bug into the case itself. Always assign the case value to a named variable on its own line.
{# safer pattern for case / when #}
{%- assign tier = 'standard' -%}
{%- if customer.tags contains 'wholesale' -%}
{%- assign tier = 'wholesale' -%}
{%- elsif customer.tags contains 'vip' -%}
{%- assign tier = 'vip' -%}
{%- endif -%}
{%- case tier -%}
{%- when 'wholesale' -%}{{ product.price | times: 0.7 | money }}
{%- when 'vip' -%}{{ product.price | times: 0.85 | money }}
{%- else -%}{{ product.price | money }}
{%- endcase -%}
The pre-assigned tier is a single value with no compound logic. The case block reads cleanly and survives a code review.
When parentheses look like they work but do not
Liquid silently ignores parentheses in if conditions. The parser does not throw an error. The output looks right on the test product you check, then breaks on the third product you forgot about.
{# this looks safe, isn't #}
{% if (product.available and product.tags contains 'sale') or product.compare_at_price > product.price %}
<span class="sale-badge">On Sale</span>
{% endif %}
Run that and the parentheses are stripped before evaluation. You are right back to the right-to-left bug. Theme Check does not flag it. Production breaks at 11pm.
The same rule applies inside unless, elsif, and case / when. There are no parentheses. There is no precedence. There is only right-to-left. Plan accordingly.
What to audit in your theme this week
Run a grep across snippets/ and sections/ for any {% if %} or {% unless %} that contains both and and or on the same line. Each match is a candidate for the right-to-left bug. Open every one of them and rewrite using the named-boolean pattern above. The shell version:
grep -rn "{% if .* and .* or " snippets/ sections/
grep -rn "{% unless .* and .* or " snippets/ sections/
Most themes I audit return between 3 and 12 hits. The Factory Direct Blinds theme had 7 before the audit. Three were on PDP discount logic. Two were on cart-drawer eligibility. One was on a B2B-only gift-card snippet that had been broken for an entire quarter without anyone noticing because the test products were all eligible.
The other place this trap hides is inside translation files for multi-locale stores. Liquid filters chained inside a translations lookup can mix and / or indirectly through string interpolation. Audit translation snippets the same way you audit theme snippets.
Why Theme Check does not catch this
Theme Check ships roughly 60 rules in its 2026 release covering deprecated tags, missing image_url widths, raw CDN URLs, and unbalanced delimiters. Operator precedence is not one of them. The reason is structural: the linter validates syntax tree shape, not semantic intent. Both if a and b or c and if (a and b) or c produce the same parse tree from the linter’s perspective; only the runtime evaluator distinguishes them, and the runtime never reports a precedence error because the language has no precedence rule to violate.
That gap is why this bug ships to production so consistently. Theme Check passes, the developer believes the file is clean, the merchant signs off the staging review, the bug runs live for weeks before someone notices the wrong products are flagged. The defence is procedural, not tooling: every multi-condition if gets a code review, and every code review checks for the right-to-left trap.
If you maintain a Shopify theme repo with CI, the closest you can get to automation is a pre-commit hook that grep-fails any {% if %} line containing both and and or. Aggressive but cheap. The hook flags every potential precedence trap and forces the dev to either rewrite or sign off.
How to verify your fix in 5 minutes
- Add a debug print above the
ifblock:{{ has_sale_tag }} | {{ has_compare_price }} | {{ product.available }}. Save and reload the storefront. - Walk three test products: one available + on sale, one unavailable + on sale, one unavailable + with compare-at-price but no sale tag. Confirm the badge fires only on the first.
- Run Theme Check. Verify zero
LiquidTagwarnings on the file. Theme Check passing is necessary but not sufficient because the precedence bug is invisible to the linter; only manual verification across edge-case products catches it.
If you ship the fix on a live theme, duplicate first. Liquid bugs that change conditional flow are the worst kind of theme regression because the visual diff is small and the logic diff is total.
The takeaway
- Liquid evaluates
andandorstrictly right to left. No precedence between them. - Parentheses do not work. The parser drops them before evaluation.
- Push compound conditions into named booleans assigned before the
if. - Theme Check does not catch precedence bugs. Manual verification across three product states is the only reliable test.
- Audit every multi-condition
ifin your sale-badge, discount, and B2B-eligibility snippets this week.