Updated · 11 min read
Liquid for lifecycle marketers — the complete Braze reference
Picture an email going out to fifty thousand people. The subject line is sharp, the offer is good, the design is on-brand. Then the first message lands and the greeting reads "Hi {{${first_name}}}," — raw template code, exposed in the inbox. Liquid — the templating language Braze uses to inject personalised values into messages — is the bit that was supposed to swap that placeholder for a real name. It's also where the most embarrassing lifecycle bugs live: raw template strings in subject lines, dates rendered in the wrong century, external API calls hammering a service at 3 a.m. This guide is the working reference: the filters you'll actually use, and the four habits that separate a working program from one that occasionally ships nonsense to a hundred thousand people.

By Justin Williames
Founder, Orbit · 10+ years in lifecycle marketing
Why Liquid sits underneath every Braze send
Every personalisation bug you've ever shipped was a Liquid bug wearing a subject line.
Anything dynamic in a Braze message — a first name, a renewal date, a loyalty balance, a paragraph that only shows for paying users — runs through Liquid at send time. Liquid takes the template you wrote and the data Braze holds about each user, and stitches them together one message at a time. Braze's flavour is the open-source Shopify library plus a handful of Braze-specific tags, the most useful of which are {% connected_content %} (calls an external API mid-send) and {% abort_message %}(cancels the send for a single user when the data's broken).Source · BrazeBraze Liquid documentationOfficial Braze Liquid reference including all filters, tags, and Braze-specific extensions.www.braze.com/docs/user_guide/personalization_and_dynamic_content/liquid
When an expression fails — an attribute is empty, a date is malformed, a filter is misspelt — Braze does one of two things: render the fallback you specified, or ship the raw {{ ${attribute} }} text to the inbox. The second one happens more often than teams realise, because the profiles QA samples in preview tend to be the ones with full data. Thin profiles — the ones missing a first name or a renewal date — surface the bug only once the send goes out. For judgement about whether to personalise a surface in the first place, not just how, the personalisation guide covers where the trust line sits.
Dates — the six format strings that cover almost every send
The classic Liquid date bug is the renewal reminder that lands at 10 a.m. local time saying "your trial ends tomorrow" — when actually it ends in four hours. Format and time zone are the two levers, and Braze follows strftime, a Unix-era convention where %B means "full month name" and %Y means "four-digit year." Six formats cover nine out of ten lifecycle cases:
%B %d, %Y→ "April 20, 2026" (full date, US-style)%d %B %Y→ "20 April 2026" (day-first, UK/AU)%Y-%m-%d→ "2026-04-20" (ISO, always unambiguous)%b %d, %Y→ "Apr 20, 2026" (abbreviated, for subject lines)%A→ "Monday" (day name only)%l:%M %p→ "2:30 PM" (12-hour clock)
A formatted date with a fallback, written in the order the filters fire: {{ ${renewal_date} | date: "%B %d, %Y" | default: "your renewal date" }}. Filters are pipes — each one takes the value on its left, transforms it, and passes the result to the right. Read this as: take the renewal date, format it, and if anything in that chain comes out empty, substitute the fallback. Wrong order, wrong output: a default placed before the date filter would mean the fallback string gets fed to the date formatter, which fails.
Two gotchas worth burning into memory. First, dates stored as epoch integers — Unix timestamps, the seconds-since-1970 number — parse differently from ISO strings, so check the actual format of your attribute before assuming the filter just works. Second, time zones default to UTC unless you pass the zone as a second argument: | date: "%B %d, %Y", "Australia/Sydney". The number of "your trial ends tomorrow" emails landing eight hours off is larger than anyone wants to admit.
Text transforms and the safe-name greeting
Three problems show up over and over in name fields: trailing whitespace from the signup form, all-caps inputs from users who typed in caps lock, and entirely empty profiles. The text filters that earn their keep dispose of all three.
The set: | capitalize (first letter uppercase, rest lowercase), | upcase, | downcase, | truncate: 80 (hard character cut with ellipsis), | truncatewords: 5 (word-aware version of the same), | strip (trims whitespace from both ends).
Filters chain left-to-right. The safe-name greeting is worth memorising because it disposes of the three name-data problems in one expression:
{{ ${first_name} | strip | downcase | capitalize | default: "there" }}
Whitespace normalised, the user who typed "JUSTIN" in the signup form lowercased and title-cased back to "Justin", and "there" when the whole chain comes out blank. Use it everywhere you greet by name.
One honest limitation: Liquid has no true title-case filter. | downcase | capitalizeonly uppercases the first letter of the entire string, not each word — so "mary anne" becomes "Mary anne," not "Mary Anne." For multi-word title case you'd need to split the string, capitalise each word, and join. Possible, annoying, and in practice single first-name greetings are the only place it matters.
Math — when to compute inside the message
Sometimes the value you want to show isn't in the user profile directly. It's the difference between two numbers, a percentage, a countdown. Liquid does the arithmetic inline so you don't need to pre-compute every variant in your data layer.
The filter set: | plus: 10, | minus: 3, | times: 2, | divided_by: 4, | modulo: 3, | round: 2, | ceil, | floor, | abs.
Useful shapes. A loyalty points reminder: "{{ ${points_balance} | minus: 500 }} points to the next tier". Trial countdowns computed from a stored end date. Progress bars — completed steps divided by total, times 100, rounded.
The trap: | divided_by does integer division when both sides are integers, meaning it throws away the decimal. "{{ 7 | divided_by: 2 }}" renders as "3", not "3.5". Force floating point — proper decimals — by dividing by a decimal literal: | divided_by: 2.0. Or chain | round: 1 if you want to control how many decimals show up. This behaviour is inherited from Shopify Liquid, which is worth knowing so you stop being surprised every time.
Control flow — branching, and the abort that saves campaigns
Personalisation isn't just substituting one value. Sometimes the whole paragraph needs to change based on who the user is. Free user gets one CTA, paid user gets another. Australian user sees the AU price, US user sees USD. That's control flow — the if/else logic that picks which version of the copy to render for each person.
{% if %} / {% elsif %} / {% else %} / {% endif %} branch on attribute values. Plan-based copy switches (pro vs free vs enterprise), lifecycle-stage CTAs, country-specific messaging — all if blocks.
The single most useful control-flow tag, though, is Braze-specific: {% abort_message %}. It cancels the send for the current user and logs the reason to message-level analytics. Use it as a hard guard — if a critical attribute is missing, kill the message rather than ship it broken:
{% if ${first_name} == blank %}
{% abort_message("No first name") %}
{% endif %}
The user doesn't receive the broken email, the abort reason shows up in analytics when you're debugging, and the rest of the send rolls on as normal — only the affected message is killed. Much better than sending "Hi ," to fifty thousand people and finding out from a support ticket.
{% assign %} creates a reusable variable. Worth reaching for when a value is referenced multiple times in a template — compute once, reference many — or when nesting filters six deep makes the code unreadable. Pull the intermediate result into a named variable and the next person to touch the template will thank you.
Connected Content — calling APIs at send time
Some data is too volatile to live as a Braze attribute. Inventory levels change minute-to-minute. Product picks come from a separate ML service. Today's exchange rate is, well, today's. Connected Content is the bridge — Liquid hits an external API at the moment of send and drops the response into the message.
{% connected_content %} fetches an external API response at send time and exposes the JSON — the structured data the API returns — as template variables you can reference like any other Liquid value.Source · BrazeConnected ContentBraze's feature for pulling external API data into personalisation at send time.www.braze.com/docs/user_guide/personalization_and_dynamic_content/connected_content/about_connected_contentReal-time inventory, product recommendations, CMS content, anything too volatile to store as a Braze attribute.
The shape: {% connected_content https://api.example.com/recs?user={{ ${external_id} }} :save recs :cache_max_age 3600 %}, then reference {{ recs.product_name }}.
Three non-negotiable production habits. Cache the response — the :cache_max_age value is in seconds — or you'll hammer your API on every single send. Include :retry so a transient network blip doesn't blank out the block. Have a template-level fallback for when the API returns nothing useful. A broken Connected Content block that ships an empty paragraph is worse than not personalising at all — at least not-personalising doesn't look broken.
Four habits that prevent the silent bugs
Most Liquid disasters share a property: nobody spotted them during QA. They surface in production, in the thin-data profiles, after the send button's been pushed. The discipline is preventative, and there are four habits worth holding. Hold the line and you won't ship "Hi {{ ${first_name} }}," to the inbox:
Default every personalised field.No exceptions. "Hi there" beats "Hi " every time. Add the default even when an attribute looks fully populated — data surprises you eventually, usually at 4 p.m. on a Friday.
Abort before personalising anything critical. If the message is useless without a specific attribute — a shipment email with no tracking number, a renewal reminder with no renewal date — abort with {% abort_message %}. Cancel it cleanly. Don't ship the broken version.
Test with a sparse-data profile.Braze's preview tool lets you target specific users for test sends. Include at least one test profile with blanks across the personalisation set. That's where the fallback paths either work or don't — the only way to find out before production does.
Lint before launch. Linting is automated scanning — a tool that reads the Liquid and flags unbracketed attributes, missing defaults, and syntax errors before the campaign goes live. Preview won't catch every edge case, especially around time zones and missing attributes — the two bug classes that show up in production and nowhere else. The Orbit Email Render QA skillruns this check on every template Orbit generates, automatically. If you're not using it, build something equivalent. Don't rely on eyeballing.
Read to the end
Scroll to the bottom of the guide — we'll tick it on your reading path automatically.
Frequently asked questions
- What is Liquid in Braze?
- Liquid is the templating language Braze uses for dynamic content — variable insertion, conditionals, loops, and filters. Syntax: {{ variable }} for output, {% tag %} for logic, pipe filters for transformation. In Braze specifically, Liquid accesses custom attributes ({{${first_name}}}), event properties, and connected content (external API calls). Liquid is how a single template renders 10M unique messages without hard-coding one per user.
- How do I insert a user's first name in a Braze template?
- {{${first_name}}} is the basic syntax. For fallback when the attribute is missing, use {{${first_name} | default: "there"}} — which renders "there" for users without a first name. Always use a fallback for human-addressing personalisation; "Hi ," with a missing name reads as a broken template and damages trust. Alternative: wrap the entire greeting in an {% if %} block for per-segment personalisation logic.
- What are common Braze Liquid mistakes?
- Five traps. (1) Missing default filters — "Hi {{${first_name}}}," renders "Hi ," when the name is empty. (2) Unescaped user input in connected-content URLs, opening XSS or injection attacks. (3) Deeply-nested conditionals nobody can debug. (4) Hardcoded dates that don't update with the send. (5) Referencing event properties that may not exist (fire only when the triggering event has the expected property, else log and skip).
- How do I format dates in Braze Liquid?
- {{ event_properties.${purchase_date} | date: "%B %d, %Y" }} — the date filter with strftime tokens. Common tokens: %B full month, %b short month, %d day, %Y year, %A full weekday, %I 12-hour, %p AM/PM. For relative dates ("3 days ago" or "in 2 hours"), Liquid has no built-in — compute in the triggering event or via a separate connected content call.
- Can I call APIs from Braze Liquid?
- Yes, via connected content: {% connected_content https://... %}. The call executes server-side at send time, injecting the API response into the template. Use cases: real-time inventory levels, top-3 product picks from an ML ranker, dynamic coupon generation. Performance caveats: the call is synchronous and blocks send; slow external APIs throttle your whole send pipeline. Cache responses and set conservative timeouts. Always provide a fallback in case the API fails.
This guide is backed by an Orbit skill
Related guides
Browse allThe SMS playbook from the operator's seat
SMS is the highest-engagement and highest-risk channel in the lifecycle stack. Here's the compliance architecture, the copy discipline, and the frequency rules that keep SMS from destroying the goodwill it's uniquely positioned to create.
Generative AI for lifecycle content: where it earns its place and where it embarrasses you
Generative AI inside lifecycle ESPs has moved from novelty to default in 18 months. BrazeAI (formerly Sage AI), Iterable Copy Assist, Klaviyo's subject line generator — they all promise per-message copy at scale. Some uses are genuinely useful. Others are a fast path to brand drift, factual errors, and reputational damage. Here's the line.
Braze naming conventions that survive a Friday afternoon
Every Braze workspace eventually becomes an archaeological dig. The convention that actually holds is four dimensions, six seconds to apply, and enforced by tooling rather than a Notion page nobody reads.
Braze, Iterable, Customer.io, HubSpot — what each actually gets right and wrong
Every ESP vendor's deck claims a category leader position. None of them mean it the way you'd want. Each platform suits a specific shape of program — and the migration disasters happen when a team picks the one with the prettiest deck instead of the one their actual use case lives inside.
Custom attributes: the data design that decides what your program can do
Custom attributes are infrastructure. Designed well, they enable every future campaign. Designed badly, they become the reason "can we segment on X?" is a multi-week engineering project instead of a 15-minute one. Here's the design discipline that prevents the mess.
Personalisation that doesn't feel creepy
There's a line between personalisation that earns trust and personalisation that breaks it. It's not where most people think it is — it's about how you signal what you know, not what you know. Here's the line, how programs cross it without noticing, and the patterns that keep you on the right side.
Found this useful? Share it with your team.
Use this in Claude
Run this methodology inside your Claude sessions.
Orbit turns every guide on this site into an executable Claude skill — 63 lifecycle methodologies, 91 MCP tools, native Braze integration. Free for everyone.