Customer.io Liquid Anti-Patterns: Six Things That Silently Break When a Property Goes Null

On 1 August 2012, Knight Capital Group deployed new code to seven of its eight equity-routing servers. One server got missed. That eighth server still carried a dormant function called Power Peg, deactivated years earlier but never deleted from the codebase. The new deployment happened to reuse a feature flag that Power Peg also responded to. The moment the market opened, the dormant code started routing live orders.

Forty-five minutes later, Knight had lost more than $460 million and taken several billion dollars of unwanted positions. The stock collapsed 75% within two business days. The SEC's investigation later confirmed something quieter and more damning: 97 automated emails warning of the deployment error had gone out before the market opened. Nobody acted on them.

The Knight Capital disaster is a story about code that didn't fail. It ran perfectly, doing exactly what nobody wanted. The warning signs were present and they were ignored. Customer.io Liquid templates fail the same way. Not with errors. With quietly wrong output, on a measurable percentage of recipients, that nobody flags because nothing technically went wrong.

Most production Customer.io workspaces have their own version of that eighth server. This post covers the six Liquid patterns most likely to be sitting in your templates right now, ready to render blank, broken, or wrong-personalised content the moment a property goes null. Each one is a one-afternoon fix once you know what you're looking for. At the end is a QA recipe that catches all of them in twenty minutes.

Why Liquid breaks silently in Customer.io specifically

Customer.io's Liquid implementation has three primary scopes: customer attributes, event properties, and the journey (or trigger) object. The official documentation on personalising messages with Liquid makes one thing clear: messages will fail to send if they reference data that doesn't exist on a profile or an event.

What the docs don't dwell on is the more common case. The message does send. The Liquid resolves. The output is just wrong. A blank subject line. A "Hi {{customer.first_name | default: 'there'}}" that says "Hi false" because someone set the attribute to a boolean. A for loop that renders an empty bulleted list. A date that reads "01 January 1970" because the timestamp came through as zero.

These failures don't show up in your delivery metrics. They show up in your engagement metrics, weeks later, in the small but persistent gap between cohorts who got the message right and cohorts who didn't. And because engagement is the primary signal mailbox providers use to set sender reputation, broken personalisation eventually becomes a deliverability and sender reputation problem. Per MailReach's 2025 roundup of Validity's benchmark data, one in six legitimate marketing emails already misses the inbox. Litmus's 2025 State of Email research puts the underlying issue more plainly: 12% of email marketers say they can't fully QA highly personalised content. You don't want to add to that number with avoidable Liquid bugs.

This post is the production-grade companion to the practical Liquid personalisation tutorial we published earlier. That one teaches you how to use Liquid. This one shows you what breaks at scale.

Anti-pattern #1: the unguarded {{ event.property }}

This is the single most common cause of blank subject lines in Customer.io. A campaign references {{ event.product_name }} in the subject. For 97% of triggers the property is there. For 3% it's missing, malformed, or null. Those 3% get a subject line that reads "Your order is ready". Two words missing. Quietly wrong.

The fix everyone reaches for is the default filter:

{{ event.product_name | default: "your order" }}

That works for missing properties. But the Customer.io Liquid syntax list and the upstream Shopify spec define default more precisely: it returns the default if the input is nil, false, or empty. Three different conditions. They are not interchangeable.

If the property is genuinely missing from the event payload, default catches it. If the property is present but explicitly null, default catches it. If the property is present but an empty string, default catches it. If the property is present and the value is false, default also catches it. And that last case is the trap.

The allow_false trap

Take a real example. You have an event property event.is_returning_customer, a boolean. For new customers it's false. You write:

{% if event.is_returning_customer | default: false %}Welcome back{% else %}Welcome{% endif %}

This will say "Welcome back" to every new customer. The default: false doesn't do what it looks like it does. The filter sees the input false, treats it as falsy, returns the default... which is also false. The {% if %} then evaluates false, falls through to the else branch, and you get the wrong greeting only on the cases where the variable was actually missing.

The fix is allow_false:

{{ event.is_returning_customer | default: false, allow_false: true }}

With allow_false: true, an explicit false value is preserved. The default only fires on nil or empty input. If you have any booleans in your event payloads, audit every default: filter that touches them.

The legacy Liquid workspaces where default doesn't even exist

Before you fix anything: confirm your workspace is on the latest Liquid version. The Liquid upgrade documentation explains that older workspaces use a previous Liquid version where the default filter is not available and you'd be writing {% if x != blank %} instead. The != blank and == blank syntax was removed in the latest version. If your team is mixing the two patterns, half the templates are using a filter that doesn't exist for them and the other half are using a comparison that no longer works. Both fail silently. Check your version first.

A lot of Liquid bugs are actually downstream of weak event schema design. If properties are inconsistently typed (sometimes a string, sometimes null, sometimes missing entirely), no defensive Liquid is going to fully save you. Fix the schema and the Liquid problem shrinks.

Anti-pattern #2: trigger data referenced outside an event-triggered campaign

A template was originally built for an event-triggered campaign. It references {{ event.order_id }} and {{ event.product_name }}. Someone duplicates the template into a segment-triggered campaign for a follow-up. The original template still works in its original campaign. The duplicate doesn't, but only sometimes, and the team doesn't notice for two months.

This happens because the event scope only exists for event-triggered campaigns. In segment-triggered, broadcast, and API-triggered campaigns, the equivalent data lives in trigger or has to be looked up from customer attributes. The Customer.io documentation on personalising messages with trigger data flags this explicitly: when you change a campaign's trigger type, the UI warns you that any liquid pulling event data needs updating. Most teams click through that warning because they're duplicating, not changing.

The fix is to standardise the lookup pattern. If your data lives on the customer record, reference {{ customer.last_order_id }}. If it lives on the event, the campaign has to be event-triggered. Mixing scopes in a duplicated template is where this breaks. Audit every campaign that was duplicated from a different trigger type. The bugs are concentrated there.

If you're newer to scoping rules and want the basics, the Liquid basics walkthrough covers them at a slower pace.

Anti-pattern #3: Liquid date formatting on Unix timestamps vs ISO 8601

The same | date: filter behaves differently depending on whether the input is a Unix epoch integer or an ISO 8601 string. That mismatch is the source of dates that render as "01 January 1970" (the Unix epoch) and dates that render as the literal string 2026-04-15T14:30:00Z. The trickiest case is the one where dates render correctly in preview but wrong in production, because preview data and production data have different shapes.

Three rules keep this clean.

First, know which shape your data is in. Customer.io's own event timestamps are Unix epoch integers (seconds since 1970). Properties on customer attributes might be Unix, might be ISO 8601 strings, might be JavaScript milliseconds (Unix × 1000). If your date: filter doesn't match the shape, you get garbage.

Second, the %-d and %-m format flags (day or month without leading zeros) are a POSIX extension. They are not portable across every strftime implementation. They work in Customer.io's current rendering engine, but if you're writing Liquid that gets shared with other systems or rendered in tests using a different engine, the dash flag can produce inconsistent output. Use %e (space-padded day) or just accept the leading zero with %d if portability matters.

Third, the date: filter does not shift the value into the recipient's timezone. That's a separate problem with a separate fix. If your campaign reads "Your appointment is at 9:00 AM" and the customer is in Sydney while your data is in UTC, the filter has done its job correctly. Your data layer needs the timezone offset added before the value ever reaches Liquid.

A safe pattern for a Unix integer event timestamp:

{{ event.created_at | date: "%e %B %Y at %H:%M" }}

A safe pattern for an ISO 8601 string:

{{ customer.signup_date | date: "%e %B %Y" }}

If the same template needs to handle both shapes, that's a sign the upstream data is inconsistent and the real fix is at the schema layer, not the template layer.

Anti-pattern #4: {% for %} loops over an empty relationship

Order confirmation email. There's a {% for item in event.line_items %} loop that renders each product. For 99% of orders this works. Then a refund or a free trial or an edge-case order comes through with zero line items. The loop body never fires. What renders is an empty <ul></ul>, an orphaned header that says "Your items:", and three lines of whitespace where the products should be.

Two safer patterns.

Pattern one is the explicit size check:

{% if event.line_items.size > 0 %}
  <h3>Your items:</h3>
  <ul>
  {% for item in event.line_items %}
    <li>{{ item.name }}</li>
  {% endfor %}
  </ul>
{% endif %}

Pattern two is the for ... else construction, which Liquid supports natively:

{% for item in event.line_items %}
  <li>{{ item.name }}</li>
{% else %}
  <p>No items on this order.</p>
{% endfor %}

The else branch fires when the collection is empty. Cleaner than the size check when you actually want to render an empty state message.

Note: in the latest Customer.io Liquid version, {% if x == blank %} and {% if x == empty %} are not interchangeable, and the legacy != blank syntax has been removed. Use .size > 0 or the for ... else form. Don't mix.

Anti-pattern #5: snippets nested inside snippets

Snippets are how you keep email components consistent across campaigns. Footer, header, social block, legal disclaimer. The pattern works well until someone nests snippets inside other snippets to share fragments across components. Then three things go wrong, in increasing order of subtlety.

First, each snippet has a 16 KB size cap, with a total of 5 MB across all snippets in the workspace. A snippet that pulls in three other snippets at render time isn't bounded by the 16 KB cap of the parent. It can render much larger. That's not a bug, but it's a constraint to be aware of when you're estimating template size for a high-volume send.

Second, snippets cannot read variables defined only inside the message body. If your nested snippet references {% assign foo = ... %} that lives in the parent template, the variable is undefined inside the snippet. The output is silently empty. This is documented but easy to miss when you're refactoring.

Third, every level of snippet nesting adds parse and render cost. On a one-off send, this is invisible. On a 500,000-recipient batch, multi-layer snippet nesting measurably slows rendering. There's no published benchmark, but the practical rule from running it at scale is: flatten snippets one level deep. Never two. If you need shared fragments across snippets, promote them to top-level snippets and reference them from both parents, rather than nesting one inside the other.

For brand consistency at the component level, the modern alternative is the Customer.io Design Studio component library, which is designed for exactly this use case and avoids the snippet-nesting trap.

Anti-pattern #6: the QA gap that lets all of this through

The five patterns above are mechanical. Once you know what to grep for, fixing them is a couple of hours of work. The harder problem is preventing the next one. None of these bugs fail in QA because QA usually means: send a preview to yourself, look at it on desktop and mobile, check the links. Your own profile doesn't have a null event.is_returning_customer. The preview data Customer.io generates doesn't include an empty line_items array. The bugs only surface in production, on the small percentage of profiles that are different from yours.

The 20-minute QA recipe below is the fix.

The 20-minute QA recipe: a Liquid edge-cases test segment

Build a permanent test segment in your workspace containing five to ten profiles, each engineered to break one specific anti-pattern. Route every production campaign through it as a preview before launch. If any rendered output looks wrong, fix before launch.

Use a manual segment in Customer.io so the membership is stable. Name it "Liquid Edge Cases" or similar so it's obvious in the segment list.

What each test profile should contain

A practical set:

  • Missing properties: a profile with the minimum required fields and no optional attributes. Catches unguarded {{ customer.x }} references.
  • Empty strings: a profile where first_name, company, and similar string fields are explicitly "". Catches the case where default doesn't fire because the field exists but is blank.
  • False booleans: a profile where every boolean attribute is explicitly false. Catches the allow_false trap.
  • Empty relationships: a profile with no related orders, no line items, no events. Catches unguarded {% for %} loops.
  • Weird characters: a profile with apostrophes, ampersands, em dashes, and a non-ASCII name (Müller, O'Brien, Søren). Catches HTML escaping issues and font fallback issues.
  • Boundary dates: a profile with a signup_date of 1970-01-01 (Unix epoch zero) and another of a far-future date. Catches timezone and date-format bugs.

You can build the segment in twenty minutes. Once it's there, the cost of using it is a single dropdown selection on every preview.

Wiring it into your launch checklist

One line in your team's pre-launch document: "Preview against the Liquid Edge Cases segment. If any output looks wrong, fix before launch."

If you already run A/B testing properly in Customer.io, this is the same discipline applied one layer earlier. Catch the bug in preview, not in the test cell.

The one thing to do this afternoon

Pick the anti-pattern you most suspect is live in your workspace. For most teams, it's #1 or #2. Grep your campaign templates for event. and trigger. references. Audit the worst three. Build the test segment. The whole exercise is one afternoon's work and it removes a class of bug that quietly compounds against your engagement metrics every week.

Customer.io gives you Liquid as a first-class personalisation tool. That flexibility is why it can also fail quietly when you misuse it. The fix is the same as every other production engineering problem: make the failure modes visible before they reach customers.

Frequently asked questions

Q: What does the default filter actually do in Customer.io Liquid?

The default filter returns the supplied default value if the input is nil, false, or empty. So {{ event.first_name | default: "there" }} returns "there" if event.first_name is missing, explicitly null, or an empty string. It also returns the default if the value is false, which can be a problem with boolean fields. Use allow_false: true if you need to preserve false as a legitimate value.

Q: Why is my Customer.io Liquid rendering blank?

The most common cause is an unguarded reference to a property that doesn't exist on every profile or event. Wrap the reference in a default filter, or check with an {% if %} block first. The second-most-common cause is scope: {{ event.x }} only works in event-triggered campaigns. If your campaign is segment-triggered, the property won't resolve and the output will be empty.

Q: How do I add a fallback for a missing event property in Customer.io?

Use the default filter: {{ event.product_name | default: "your order" }}. This returns the fallback string when the property is missing, null, or empty. If you're on the legacy Liquid version, the equivalent is an {% if %} block, but you should upgrade to the latest Liquid version first.

Q: What's the difference between nil, false, and empty string in Liquid?

nil means the value doesn't exist on the object. false is the boolean value. An empty string is "". All three are "falsy" in Liquid, meaning {% if x %} treats them the same way. The default filter also treats them the same way unless you pass allow_false: true to preserve explicit false values.

Q: Why does my subject line come through blank for some recipients?

Almost always an unguarded property reference where the property is missing or null for the affected profiles. Check every Liquid expression in the subject line and add a default: fallback to each one. Subject lines fail more visibly than body content because there's nowhere to hide an empty subject.

Q: Can I use {{trigger.property}} in a segment-triggered campaign?

No. The trigger and event scopes only exist in campaigns triggered by an event or webhook. In segment-triggered campaigns, you reference customer attributes via {{ customer.x }} instead. If you duplicated a template from one trigger type to another, audit every event. and trigger. reference.

Q: How do I format a Unix timestamp in Customer.io Liquid?

Use the date filter directly: {{ event.created_at | date: "%e %B %Y at %H:%M" }}. Customer.io interprets the input as a Unix epoch when it's an integer. For ISO 8601 strings, the same filter works but make sure your input is the expected shape. Inconsistent input shapes are the most common source of date rendering bugs.

Q: Why does %-d not work in my date format string?

The %-d flag (day of month without leading zero) is a POSIX extension to strftime. It works in Customer.io's current rendering engine but is not portable across every strftime implementation. If you're seeing it render as the raw %-d string instead of being interpreted, your engine doesn't support the dash flag. Use %e (space-padded day) or %d (zero-padded day) as portable alternatives.

Q: How do I check if an array is empty in Customer.io Liquid?

Use .size > 0: {% if event.line_items.size > 0 %} ... {% endif %}. Or use the for ... else pattern, which renders the else branch when the collection is empty. Don't use != blank in the latest Liquid version, where it's no longer supported.

Q: What's the size limit for a Customer.io snippet?

The default cap is 16 KB per snippet, with a workspace-wide total of 5 MB across all snippets. Both can be increased on request via support, but the defaults are sensible. If a single snippet is close to 16 KB, it's a sign the content should be split or moved to a Design Studio component.

Q: Can I nest snippets inside other snippets in Customer.io?

Yes, but with caveats. Nested snippets cannot read variables defined only in the parent message body, and every level of nesting adds parse and render cost. The practical rule from running this at scale is to flatten snippets one level deep and never go two levels deep. For shared fragments across snippets, promote them to top-level snippets and reference them from both parents.

Q: How do I QA Liquid personalisation before a campaign goes live?

Build a permanent test segment of five to ten profiles, each engineered to break one anti-pattern: missing properties, empty strings, false booleans, empty relationships, weird characters, boundary dates. Preview every campaign against this segment before launch. The setup takes twenty minutes. The ongoing cost is a single dropdown selection.

Q: Is there a way to preview Liquid against multiple test profiles in Customer.io?

Yes. In the campaign preview, you can select any profile from your workspace as the preview target, including profiles in a manual segment. The Liquid Edge Cases segment described above is exactly this use case. Step through each profile in the segment and visually check the render.

Q: Why does the default filter trigger when my value is false?

By default, the default filter treats false as a falsy value and returns the fallback. To preserve an explicit false value, pass allow_false: true: {{ event.flag | default: false, allow_false: true }}. This is the most common boolean-handling bug in Customer.io Liquid.

Q: How do I know if my workspace is on the latest version of Liquid?

The Liquid upgrade page in your workspace settings shows the current version and whether an upgrade is available. The latest version supports the default filter and removes the legacy != blank comparison. If you're seeing inconsistent behaviour where some templates use default and others use != blank, your workspace is mid-upgrade or has templates from both eras.

Sources

David Crowther
Book a free consultation →

On our call, you'll be speaking with David Crowther, founder of NerveCentral.

Our initial consultation is not a sales call—you'll talk, I'll listen and ask questions—then I'll come back to you within 48 hours with our best ideas on how to grow your business.