What it means
A merge variable is a named slot in a template. You write the message once, drop {{firstName}} where the name goes, and the sending engine looks up firstName on each contact record and swaps the value in as it renders that recipient's copy. One template, one review, one approval, and a thousand messages that each address a specific person.
The mechanism is old. Word processors called it mail merge in the 1980s, Mailchimp calls the syntax merge tags, Salesforce calls it merge fields, HubSpot calls it a personalisation token. The vocabulary drifted; the behaviour did not. What changed is the stakes. A mail-merge slip in a printed letter cost one envelope. The same slip in an outbound campaign ships Hi {{firstName}}, to four hundred people who now know exactly how the message was made, and on a DM channel it hands each of them a reason to report you.
The interesting part of merge variables is therefore not the substitution. It is what happens on the rows where the value is not there, which is always more rows than you think.
Syntax families and why we use double braces
Four conventions are common in the wild:
{{firstName}}: double brace. The de facto standard in modern outreach tooling and our convention throughout Pinlyx.{firstName}: single brace. Readable, and a direct collision with spintax.%%FIRST_NAME%%and*|FNAME|*: legacy email service provider syntaxes. Ugly on purpose, because nothing else in an email body looks like that.${firstName}: borrowed from templating languages. Fine in code, risky in a message body where a currency symbol is ordinary text.
The choice matters because a bulk-send pipeline runs two passes over the same string. One expands spintax, where {a|b|c} picks a variant so a thousand sends do not share one fingerprint. The other substitutes merge variables. If both grammars used single braces the parser would have to guess, and it would guess wrong on exactly the inputs that matter: a job title containing a pipe, a company name containing a brace. Double braces are unambiguous and easy to validate, because no rendered message should ever leave the system still containing {{.
The null field: three engine behaviours, three outputs
Take one template and one contact. The template is Hi {{firstName}}, saw {{company}} is hiring in {{city}}. The contact has firstName of "Maya", company of "Acme Robotics", and city of null, because the city column was blank in the import file for about a fifth of the rows and nobody checked.
Depending on which engine renders it, that one contact produces three completely different messages. The cheat sheet below shows all three side by side. The short version: a raw token is the loudest failure, an empty string is the quietest, and only a declared fallback produces a sentence a human would have written.
Fallback syntax in most engines, ours included, is a pipe inside the braces: {{city|your area}}, read as "use city, and if there is no city, use your area". Chains work the same way, so {{city|region|your area}} tries two fields before giving up. Write the fallback at the moment you write the sentence, never afterwards, because it has to fit the grammar around it.
Missing is not one thing
Here is the bug that survives every code review, because the engine is doing exactly what it was told. A CSV import produces at least four flavours of "no value", and only the first is null:
- Genuine null. The column was absent or the cell was never populated. Every fallback implementation catches this.
- Empty string. The cell existed and contained nothing. An engine that only tests
value === nulltreats an empty string as a real value and substitutes it, which is the "hiring in ." case with extra steps. - Whitespace only. A single space or a tab. Looks identical to empty in a spreadsheet, passes both null and empty-string checks, renders as
hiring in . - Junk sentinels. The literal strings
null,NULL,N/A,-,unknown, or in our own marketyok. These are real strings as far as any engine is concerned, soHi {{firstName|there}},cheerfully rendersHi N/A,.
The fix is a normalisation step before substitution, not a smarter fallback: trim the value, treat the empty result as missing, and compare the lowercased value against a short blocklist of sentinels. Do this once in the send pipeline and every template in the workspace inherits it.
Casing, titles and the shape of imported data
Real contact data is not clean. The same first-name column will contain MARIA from a system that uppercased everything, maria from a web form with no validation, maria k. from a scraped source, and Maria from the rows somebody typed by hand. Substituted raw, your opener greets people as Hi MARIA,, which reads as shouting, or Hi maria k.,, which reads as a database leak.
Title-casing on render fixes the common cases and creates smaller new ones: mcdonald becomes Mcdonald rather than McDonald, and hyphenated names need the segment after the separator capitalised too. Perfect name casing is not a solvable problem, which is the argument for normalising at import (once, reviewably) rather than at send (invisibly, every time).
Company suffixes work the same way. Trimming Acme Robotics Inc. to Acme Robotics makes a DM sound like a person wrote it, while trimming Acme Robotics A.S. in formal Turkish B2B reads as sloppy rather than casual. Trim for conversational channels, keep the legal form for anything shaped like a document.
Derived variables
Not every variable comes off a column. The useful ones are computed at render time from something you already have, and each computation carries its own failure mode; the reference table further down lists four we use constantly and what breaks each one. The general rule is to derive only from a field you trust. A first name split out of a full name is safe because the full name came from the contact. A day-part greeting derived from a timezone that was itself guessed from a phone prefix is two inferences deep, and the error lands in your first line. See contact enrichment for keeping provenance on fields like these.
Order of operations with spintax
When a template contains both spintax and merge variables, the order of the two passes is a security decision, not a preference.
Spin first, then merge. The spinner sees only the template you wrote, expands the branches, and hands a plain string to the merge pass, which then substitutes values. Contact data never reaches the spinner.
Merge first, then spin. Now contact data is inside the string the spinner parses. A company named Smith | Jones LLP injects a pipe into your template. A contact whose bio contains a brace injects a branch. At best the output is mangled; at worst a hostile record turns your template into one the sender never wrote and never reviewed. This is template injection, the same class of bug as SQL injection, with the same root cause: untrusted data reaching a parser.
Spin first, merge second, and escape any braces or pipes surviving in a merged value. If your tool does it the other way round, at minimum sanitise every merged value before substitution.
Merging into links
Merged values inside URLs need URL encoding, always: a company named Acme & Sons dropped raw into ?company={{company}} truncates the parameter at the ampersand, and a value with a space breaks the link outright in clients that do not auto-escape. Never merge into a path segment either. /demo/{{company}} 404s for every contact whose company name is not also a route, and it leaks contact data into your access logs and analytics referrers. Query parameters are the right place, and a signed token is better still.
Testing before you send
Previewing the first row of your segment tells you nothing, because the first row is almost always complete. Two habits catch the rest:
- Preview against 20 random contacts, not the top of the list. Random sampling surfaces the uppercase names, the missing cities and the company called
-. - Run a null audit over the segment before the send: for each variable in the template, count how many rows will fall back. If
{{city}}falls back on 34% of a 1,200-contact segment, you now know 408 people will read "your area", and you can decide whether that sentence still earns its place.
Two guardrails we recommend, both cheap to enforce:
- No merge variable inside the first three words of a subject line unless its fallback reads naturally alone. Inbox previews truncate, and the fallback is what a large share of your list actually sees.
- Block the batch when a personalisation-critical field falls back on more than a threshold share of rows. We use 20% as the default trigger for a warning and 40% for a hard block. Those numbers are our convention, not an industry standard, but any threshold beats none.
Why it matters
Merge variables are the difference between a template and a message. On cold outreach, a correctly merged and genuinely relevant detail is the clearest signal that a human chose to write to this person, and it is the first thing a recipient checks before deciding whether to reply or report. On DM channels the stakes beat email: a recipient who sees a raw token on Telegram or Instagram has the report button within reach, and reports are what become restrictions. The inverse holds too. Heavy personalisation does not rescue a bad list, because a perfectly merged message to somebody with no reason to hear from you is still an unwanted message. Merge variables raise the ceiling of a good list, not the floor of a bad one.
Common mistakes
- Shipping without a fallback. The most common failure by a wide margin, and the easiest to prevent with a pre-send check that refuses any body containing
{{after rendering. - Fallbacks that only fit one sentence.
{{company|your company}}reads fine in "how is your company handling this" and badly in "saw your company is hiring". Write the fallback for the sentence it lives in. - Personalising with stale data. A job title enriched eighteen months ago and wrong today makes you look like you bought a list, because you did.
- Testing only the happy path. Send yourself one preview with every optional field deliberately blanked. That single test catches most of this page.
Related concepts
- Spintax: the other half of the template pipeline, and the reason merge variables use double braces.
- Contact enrichment: where the values behind your variables come from, and how fresh they are.
- Deduplication: two records for one person means two different merged messages to the same inbox.
- Drip campaign: every step of a sequence carries its own variables and its own null risk.
- Cold outreach: the context where a merge failure costs the most.
- Deliverability: broken personalisation drives complaints, and complaints drive placement.
How Pinlyx handles it
Pinlyx resolves merge variables on every send path: Telegram, X DMs, WhatsApp, Instagram, email and the in-app composer. The composer autocompletes the fields available for the current segment, supports pipe fallbacks and fallback chains, and normalises missing values by trimming and screening junk sentinels before substitution, so an imported N/A triggers the fallback instead of shipping. Spintax expands before merge substitution and surviving braces or pipes are escaped, so a contact record cannot inject template syntax.
Before a batch leaves, the pre-send check counts fallback hits per variable across the segment and shows how many recipients each fallback will reach, and any rendered body still containing a double brace fails the send. Previews pull random contacts rather than the first rows, which is the difference between a preview and a test.