The ?? operator in Attio formulas: how to stop empty values from breaking everything
Every formula in this series has ended with the same warning, so it's time it got its own article. The most common way Attio formulas fail in real workspaces isn't a syntax error — the editor catches those. It's the formula that works perfectly on complete records and silently returns *nothing* on the rest, because one input was blank. The fix is two characters: ??.
This is part of our function-by-function series on Attio formula attributes — previously: if(), timeSpentIn(), and dateDiff(). One article, one operator: how empty values behave, where the guard goes, and how to choose fallbacks that mean something.
Table of contents
- How empty values behave in Attio
- What the ?? operator does
- Where to put the guard
- Choosing the right fallback
- The ?? operator vs. checking == null
- CRM use cases that earn their keep
- Copy-paste formulas
- Final thoughts
How empty values behave in Attio
CRM data is never complete. Enrichment misses companies, imports arrive with gaps, new records start life mostly blank. Attio's formula engine has one rule for all of it: an empty attribute evaluates to null.
The consequence is what makes this dangerous. 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">null isn't zero and it isn't false — it's *nothing*, and nothing is contagious. Multiply by it and the product is empty. Compare against it and the comparison is neither true nor false, so an [if() built on it can return nothing at all. The record doesn't error; it just shows an empty cell and quietly drops out of both sides of your filters.
That failure mode is invisible in exactly the way that hurts: the formula looks right, works on every record you spot-check, and fails only on the incomplete ones — which are usually the records that most need attention.
What the ?? operator does
?? is the null coalescing operator — a fallback:
left ?? rightIf the left side has a value, you get the left side. If it's empty, you get the right side. The canonical example:
{MRR} ?? 0"MRR, or zero if it's empty." That's the entire operator. No function call, no nesting — just a default that steps in exactly when the attribute is blank, and steps aside the moment real data arrives.
Where to put the guard
The guard belongs on each input that can be blank, before it enters the math — not bolted onto the end of the formula. Compare:
({Base fee} ?? 0) + ({Onboarding fee} ?? 0)Every record now gets an honest total, whether or not both fees are filled in. Guard only the sum, and a record with one blank fee still computes on null internally.
Same placement inside conditions. From the if() deep dive:
if(({Lead score} ?? 0) > 50, "Qualified", "Nurture")The parentheses matter — they bind the fallback to the attribute, so the comparison always sees a number. A blank score now lands deliberately in "Nurture" instead of producing an empty tier.
Rule of thumb from the pillar guide, worth repeating verbatim: wrap every numeric input in a ?? 0 fallback before multiplying or summing, or a single blank field will blank out the whole result. Enriched attributes deserve the guard by default — they're the ones most likely to be missing.
Choosing the right fallback
The fallback isn't boilerplate — it's a statement about what a missing value *means*. The same blank deserves different answers in different formulas:
?? 0— a blank counts as zero. Right for revenue math, sums, and weighted values, where missing should contribute nothing rather than destroy the total.- A large sentinel like
?? 999— a blank counts as "worst case." Right for neglect metrics: in(dateDiff({Last interaction}, today(), "days") ?? 999) > 30, an account you've *never* touched reads as 999 days of silence and lands at the top of the going-cold list — which is the honest interpretation, not a trick. - A neutral middle value — right for scores feeding a ranking, when unknown shouldn't mean either best or worst.
The trap is picking a fallback by reflex. ?? 0 on a lead score quietly files every unscored lead under "Cold" — fine if that's your intent, a silent bug if unscored leads were supposed to get human eyes. Decide what missing means first; then make the fallback say it.
The ?? operator vs. checking == null
?? has a sibling: the explicit emptiness check, {attribute} == null. They answer different questions.
Use ?? when a substitute value keeps the formula honest — the math should proceed, just with a default in the gap.
Use == null inside an if() when emptiness itself is the signal:
if({Estimated ARR} == null, "Needs enrichment", "Enriched")Here you don't want to paper over the blank — you want to surface it. Data-quality flags, "missing owner" alerts, enrichment queues: those are == null jobs. Substituting a default would hide exactly the thing the formula exists to show.
The two compose naturally: == null to flag the gap on a hygiene view, ?? to keep the downstream revenue math running despite it.
CRM use cases that earn their keep
- Blank-proof revenue math —
({Base fee} ?? 0) + ({Onboarding fee} ?? 0)— contract totals that survive partially filled records. Important because one missing line item shouldn't erase the whole number finance looks at. - Safe lead scoring —
if(({Lead score} ?? 0) > 50, "Qualified", "Nurture")— every lead gets a tier, scored or not. Important because records that silently fall out of both tiers are records nobody works. - Honest neglect counters —
(dateDiff({Last interaction}, today(), "days") ?? 999) > 30— never-contacted accounts rank as most neglected, not invisible. Important because the accounts with no interaction history are precisely the ones a going-cold view exists to catch. - Data-quality flags —
if({Estimated ARR} == null, "Needs enrichment", "Enriched")— missing data becomes a filterable state. Important because you can't fix gaps you can't see, and a hygiene view beats a quarterly data audit. - Forecast-safe weighted pipeline —
({Deal value} ?? 0) * 0.4inside stage-weighted formulas — a deal without a value contributes zero instead of blanking the forecast column. Important because a summed pipeline view is only as reliable as its least complete record. - Fallback display values —
{Preferred name} ?? {Name}— use the enriched or custom field when present, fall back to the standard one when not. Important because personalization that breaks on incomplete data is worse than no personalization.
Copy-paste formulas
Swap in your attribute names and these work as-is:
Blank-safe contract total (Currency output):
({Base fee} ?? 0) + ({Onboarding fee} ?? 0)Lead tier that never returns empty (Text output):
if(({Lead score} ?? 0) > 80, "Hot", if(({Lead score} ?? 0) > 50, "Warm", "Cold"))Going-cold flag, never-contacted accounts included (Text output):
if((dateDiff({Last interaction}, today(), "days") ?? 999) > 30, "Going cold", "Active")Enrichment gap flag (Text output):
if({Estimated ARR} == null, "Needs enrichment", "Enriched")Annualized revenue that survives blanks (Currency output):
({MRR} ?? 0) * 12Final thoughts
?? is the least glamorous thing in Attio's formula library and the one that separates formulas that demo well from formulas that run a real pipeline. The discipline is simple: assume every input can be blank, guard each one where it enters the expression, and choose fallbacks that state what missing should mean — 0 for math, a sentinel for urgency, == null when the gap itself is the story. Two characters per attribute, and your formulas stop having silent failure modes.
For the rest of the library — every operator, logic, math, date, text, and history function with CRM use cases — see the complete guide to Attio formula attributes.
And if you'd rather have blank-proof scoring, forecasting, and hygiene formulas designed and shipped for you, that's literally what we do. Get a free workspace audit or see the AI-native Attio sprint.
Need help with your Attio setup?
We migrate teams, build data models, wire automations, and train Claude agents inside your workspace. Discovery call is free.
Book a free discovery callReady when you are.
Two ways in. Pick the friction that fits.