Shopify Liquid Operator Precedence: and / or Evaluation Order (2026)

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 / or on 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 if returns 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

  1. Add a debug print above the if block: {{ has_sale_tag }} | {{ has_compare_price }} | {{ product.available }}. Save and reload the storefront.
  2. 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.
  3. Run Theme Check. Verify zero LiquidTag warnings 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 and and or strictly 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 if in your sale-badge, discount, and B2B-eligibility snippets this week.

Frequently Asked Questions

How does Liquid evaluate `and` and `or` operators?

Liquid evaluates `and` and `or` strictly right to left, with no precedence between them. This is the opposite of C, JavaScript, Python, and Ruby, where `and` binds tighter than `or`. The reliable fix is to assign each compound condition to a named boolean before the `if` block, or to nest a second `if` instead of relying on precedence. The same rule applies inside snippets rendered with `{% render %}`, in section files, and in Section Rendering API responses, so an isolated scope does not change evaluation order.

Why does my nested Liquid if condition return the wrong result?

If your `if` mixes `and` and `or` on the same line, Liquid is reading the line right to left, not the precedence order you expect from JavaScript or Ruby. `if a and b or c` evaluates as `a and (b or c)`, not `(a and b) or c`. The two outcomes diverge whenever `a` is false but `c` is true. Split compound conditions into named booleans assigned with `{%- assign -%}` before the if block. The verbosity is the price of correctness.

Does Liquid have parentheses for grouping conditions?

No. Liquid has no support for parentheses inside `if`, `unless`, `case`, or `elsif` conditions. The parser ignores them and silently throws away your intent. The only way to control evaluation order is named booleans or nested `if` blocks. This applies on every Shopify plan, including Plus, and inside Online Store 2.0 sections, JSON templates, and the Section Rendering API.

Does the render tag change Liquid operator precedence?

No. The `{% render %}` tag creates an isolated scope, but the parser still evaluates `and` and `or` right to left inside the snippet exactly as it does in the parent template. Wrapping logic in render does not change evaluation order. If you are seeing different behaviour between an `include`-based and a `render`-based snippet, the cause is scope isolation removing a parent variable, not operator precedence.

How do I test Liquid operator behaviour locally?

Run `shopify theme dev` from the theme root for a hot-reloading local server. Add temporary debug output (`{{ has_sale_tag }} {{ has_compare_price }}`) above the if block and watch the rendered HTML across two or three test products with different combinations of sale tag and compare-at price. The Theme Check linter does not catch operator-precedence bugs because the syntax is valid. Manual verification across edge cases is the only reliable test.

Book Strategy Call