New to Attio?Get 10% off when you sign up through Craftt.Try Attio free →
All articles

Arithmetic operators in Attio: what comes back out when you do maths on money

Written by

Published 30 min readTested in live Attio workspaces
Contents20 sections
  1. What the four arithmetic operators do
  2. The documentation names currency twelve times and never once for the operators
  3. How to answer the money question in your own workspace in ten seconds
  4. Why the type matters more than the rendering
  5. Multiplication is where most of the value is
  6. Addition is the operator that disappears on a blank
  7. Division is the only operator with a failure mode nobody writes down
  8. The guard that works everywhere else is a bug on a divisor
  9. What to write instead when the divisor can be missing
  10. Subtraction and the variance column
  11. Subtracting one date from another is not how you get days
  12. Precedence is real and parentheses are free
  13. The entire documented error surface of the language is one sentence
  14. Rounding is a separate decision from the arithmetic
  15. Percentages have no operator and no type
  16. When a function beats an operator
  17. CRM use cases that earn their keep
  18. Copy-paste formulas
  19. Gotchas
  20. Final thoughts

Four symbols. Four one-word descriptions. + is "Addition", - is "Subtraction", * is "Multiplication", / is "Division", with a sum apiece to prove it: 100 + 50 = 150.

Nobody needs that table. Everybody has needed arithmetic in a CRM — commission on a closed deal, a monthly figure from an annual one, margin, cost per seat, a weighted pipeline number, variance against forecast. The symbols are not the hard part.

The hard part is the question the table never answers: what type comes back out? Multiply a Currency attribute by 0.15 and you get a number. Is it money, or is it just a number that happens to be correct? Attio's documentation is exhaustive about this for the math functions and silent about it for the operators — and that gap, plus a divisor that can be zero, is where arithmetic formulas actually go wrong.

This is part of our function-by-function series on Attio formula attributes — previously: if(), timeSpentIn(), dateDiff(), the ?? operator, count(), hasBeenIn(), valueSetAt(), contains(), sum(), previousValue(), dateAdd(), formatDate(), min()/max(), valueAt(), replace()/replaceAll(), avg()/median(), round()/ceil()/floor(), and()/or()/not(), length(), unique(), abs(), today()/now(), eomonth(), day()/hour(), mod(), power(), exp()/log(), split()/splitPart(), startsWith()/endsWith(), lower()/upper(), left()/right(), date()/timestamp(), setTimezone(), number()/text(), year()/quarter()/month(), random(), phoneNumber(), currency()/checkbox()/rating(), and the comparison operators. This one covers all four arithmetic operators together, because the interesting questions are about types and blanks, and those are the same for all four.

color:var(--color-text-heading)]">Prefer to watch? There is a short video for this one: [Attio arithmetic operators: the four operators every other formula is built on. The whole library is also being built end to end in our first live session — Attio Formulas: Every Function, Built Live, free, no registration, Thursday 8 October 2026.

What the four arithmetic operators do

Attio's documentation introduces the whole operator set with one line: "Operators are symbols built into the formula language. You can use them directly in any formula." Then the four arithmetic entries, verbatim:

OperatorDescriptionExample
+Addition100 + 50 = 150
-Subtraction100 - 50 = 50
*Multiplication100 * 2 = 200
/Division100 / 4 = 25

Two things are worth noticing before anything else.

color:var(--color-text-heading)]">There is no exponent operator and no remainder operator. No ^, no **, no %. Raising to a power is [power(base, exponent) and the remainder is mod(dividend, divisor), both functions. If you came from a spreadsheet, % in particular is a reflex worth unlearning — in Attio a percentage is multiplication by a decimal and nothing else.

color:var(--color-text-heading)]">There is no string concatenation documented anywhere. The + entry says "Addition" and shows two numbers. Whether + joins two text values is not stated, and the library has no concat(), no join operator and no regex. The series has a candidate workaround built from [replace() — replace("+44~", "~", {Phone raw}) — which is still worth testing in your own workspace rather than trusting. Do not plan a formula around text addition until you have watched it work.

The documentation names currency twelve times and never once for the operators

This is the finding that made this article worth writing, and you can verify it on Attio's own functions page in about a minute.

The Math functions section opens with "Math functions perform calculations on numbers and currency values." Then twelve individual function descriptions go out of their way to name currency explicitly:

  • avg(): "Calculates the average of an array of numbers or currency values. Returns a single value of the same type as the input."
  • sum(): "Returns the sum of an array of numbers or currency values. Returns a single value of the same type as the input."
  • abs(): "Returns the absolute value of a number or currency value."
  • round(): "Rounds a number or currency value to the specified number of decimal places."
  • ceil() and floor(): the same phrasing.
  • min(), max(), mean() and median(): "an array of numbers, currency values, or dates".
  • exp(): "Returns e raised to the power of the given number or currency value."
  • log(): "Returns the natural logarithm (base e) of the given number or currency value."

Thirteen mentions of currency in that one section, counting the preamble. Two functions go further and state type preservation as a rule.

Now count the mentions of currency in the operator table.

Zero.

So Attio has documented, in writing, what happens to the money type when you take the natural logarithm of a currency value — a thing no revenue team has ever needed — and has documented nothing about what happens to the money type when you divide one. Dividing a currency value is something that happens in every workspace that has ever calculated a price per seat.

The series has run into this pattern before. The comparison operators get three words each while 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">?? gets a full explanatory sentence; [currency(), checkbox() and rating() are documented in inverse proportion to how easily they mislead you. It is the same shape every time: documentation depth tracks how interesting a function is to describe, not how much damage it can do. The operators are boring to describe and load-bearing in practice, so they get a word each.

How to answer the money question in your own workspace in ten seconds

I am not going to tell you what {Deal value} * 0.15 returns, because I have not watched it in a workspace and the docs do not say. What I will tell you is that this is a ten-second experiment and you should run it before you build anything that depends on the answer.

Make a formula attribute on Deals:

{Deal value} * 0.15

Set the output type to Currency explicitly rather than leaving it on Auto, and look at the column. Then set a second copy to Number and look at that. One of two things is true:

  • Arithmetic preserves the currency type. The Currency column renders as money, the Number column renders as a bare figure, and you simply choose the output type you want. Nothing else to do.
  • Arithmetic drops to a plain number. The Currency output type may refuse the value or render it without the money treatment, and you need currency() to put the type back:
currency({Deal value} * 0.15)

There is one piece of circumstantial evidence in the docs, and it is worth reading carefully because it cuts both ways. currency(value) is documented as "Converts a number to a currency value" — not "converts a value", which is the phrasing text() and checkbox() use. A function whose documented input is a *number* and whose output is currency exists because number-shaped money is a state the language expects you to be in. That is a hint, not a fact. Go and look.

Whichever answer you get, record it somewhere your team can find. This is exactly the kind of thing that gets rediscovered by three different people in six months.

Why the type matters more than the rendering

It would be easy to read the previous section as a cosmetic concern. It is not, and here is the part that makes it worth your ten seconds.

The type is not just how the column looks. It decides:

  • Whether the aggregations keep the money type. sum() and avg() are documented as type-preserving, which means they hand back whatever you fed them. Feed a roll-up of a formula attribute that has quietly become a plain number and the total at the top of your board is a plain number too. The type error does not stay where you made it; it propagates upward into the number leadership reads.
  • Whether it can carry a currency at all. A currency value in Attio has a unit attached. A number does not. A plain-number "commission" column is a figure with no currency on it, which is fine until the workspace has deals in two currencies and nobody can tell which figures are comparable.
  • Whether the field reads as money to a human. A reviewer who sees 4500 has to ask. A reviewer who sees a formatted money value does not.

And note what currency() cannot fix: the unit. The signature is one argument. There is no currency(value, "EUR") and nothing anywhere in the library lets a formula name a currency — the unit comes from the configuration of the attribute the formula writes into. currency() restores the *type*. The *currency* is an attribute setting, and no amount of nesting will get you there.

Multiplication is where most of the value is

Of the four, * is the one that earns its place in a CRM. Almost every genuinely useful arithmetic formula is a rate applied to an amount.

{Deal value} * 0.15

Commission, referral fee, platform cut, tax estimate — all the same shape. Keep the rate in the formula body and the number stays visible to whoever opens the attribute, which is the threshold argument from the comparison article applied to rates: a literal in one formula attribute is honest, and the same literal copied into nine formula attributes is a liability.

If the rate genuinely varies by record, put it on the record:

{Deal value} * {Commission rate}

Now the rate is data, the formula is a policy, and someone who is not you can change a rate without opening a formula editor. That is a real improvement, and it is also a new blank to worry about — see the next section.

Annualising and de-annualising is the other half of multiplication's working life:

{MRR} * 12
{ARR} / 12

Both are one-liners and both are better than letting two teams maintain two columns that are supposed to agree. If you already store both, a comparison between them is a free data-quality check: {ARR} != {MRR} * 12 finds every record where someone updated one and forgot the other.

Weighted pipeline is where multiplication pays for itself:

{Deal value} * ({Probability} / 100)

That is two operators and one honest forecast number per deal, which sum() will then roll up for you.

Addition is the operator that disappears on a blank

Every arithmetic operator inherits the behaviour the series established back in the ?? article: an empty attribute evaluates to null, and null is contagious. It is not zero. Add it to something and the whole expression comes back empty.

{Base fee} + {Onboarding fee} + {Support fee}

Three attributes, and if any one of them is blank the total is blank. Not partial. Not the sum of the two that are filled in. Empty. A record that owes you a base fee and an onboarding fee but has nothing in support shows no total revenue at all.

This is worse in a column of money than it is almost anywhere else, because an empty cell in a revenue column does not read as an error. It reads as zero revenue. Nobody investigates a zero.

The fix is the one the series has been repeating since August, and it belongs on each input, not on the result:

({Base fee} ?? 0) + ({Onboarding fee} ?? 0) + ({Support fee} ?? 0)

The parentheses are load-bearing. They bind each fallback to its own attribute so the addition never sees a null at all. And ?? 0 is the right fallback here for a reason worth stating out loud: in additive and multiplicative revenue maths, a missing amount genuinely should contribute nothing. The fallback is not a patch, it is a statement that a blank fee means no fee.

Multiplication is harsher still, and in a direction people do not expect:

{Seats} * {Price per seat}

A blank on either side empties the result — but a zero on either side gives you a hard zero, which looks like a real answer and is not. ({Seats} ?? 0) * {Price per seat} on a record whose seat count was never imported returns a confident 0. Guarding the blank made the record *less* visible, not more. Where a zero would be a lie rather than a number, say so explicitly:

if({Seats} == null, null, {Seats} * {Price per seat})

An empty cell that means "we do not know" is more honest than a zero that means the same thing in a disguise.

Division is the only operator with a failure mode nobody writes down

/ is different from the other three, and the difference is not subtle.

Addition, subtraction and multiplication are total — every pair of numbers has a sum, a difference and a product. Division is not. There is exactly one input that has no answer, and it is a number that turns up in CRM data constantly:

{Deal value} / {Seats}

Price per seat. Entirely reasonable. Now consider the records where {Seats} is 0 — a trial with no seats provisioned, a platform-fee-only contract, a row where someone typed zero because they did not know, a deal where the seat count has not been negotiated yet.

Dividing by zero is undefined. Attio's documentation does not say what the formula engine does about it. The mod() article already notes a zero divisor as one of the three reasons a remainder formula misbehaves, and the honest position is the same here: it is undefined mathematically, it is undocumented in the product, and you should not discover the answer on a live revenue column.

So treat a zero divisor the way you would treat any other value that has no answer: decide what the record should say, and say it deliberately.

The guard that works everywhere else is a bug on a divisor

Here is where I have to correct a habit this series itself has taught, in print, for thirty-nine articles.

The rule, quoted from our own pillar guide: wrap every numeric input in a ?? 0 fallback before multiplying or summing, or a single blank field will blank out the whole result. That rule is correct. It is correct for addition, for multiplication, for sum(), for comparisons, for everything the series has applied it to.

It is wrong for the bottom of a division.

{Deal value} / ({Seats} ?? 0)

Read what that does. A blank {Seats} would, on its own, have produced an empty result — which is unhelpful but harmless and honest. The guard reaches in and converts that harmless blank into zero, which is the single value a divisor must never take. The formula does not fail because the data was missing. It fails because of the guard that was supposed to protect it.

That is the general shape and it is worth naming: a fallback is a claim about what a missing value means, and 0 means something different on the bottom of a division than it means anywhere else. Everywhere else zero is the neutral element — add it and nothing happens, sum it and nothing happens, compare against it and you get a sane answer. On a divisor, zero is the one point where the operation stops existing.

The series got here honestly. The ?? 0 reflex is right so often that it stops looking like a decision. This is the exception, and it is the kind of exception that only shows up on the records nobody looks at.

What to write instead when the divisor can be missing

Guard the divisor with a condition, not a fallback, and let the condition carry the decision:

if({Seats} == null, null, {Deal value} / {Seats})

That handles the blank and still leaves a zero seat count to blow up. Catch both at once:

if(({Seats} ?? 0) == 0, null, {Deal value} / {Seats})

Read that carefully, because the 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">?? 0 is doing honest work this time. It is on the left side of a comparison, not on the divisor — it is there so the emptiness check and the zero check collapse into one test, exactly the placement rule from [the comparison operators: guard where the meaning lives, not where the symptom appears. The division itself never sees a guarded value at all.

If an empty cell is not acceptable to whatever reads the column next, return a sentinel you have chosen on purpose rather than one that fell out of the maths:

if(({Seats} ?? 0) == 0, 0, {Deal value} / {Seats})

Just be clear with yourself about what you have said. That formula asserts that a seatless deal has a price per seat of zero, which is defensible for a platform-fee contract and nonsense for a deal that has not been sized yet. If those two cases both exist in your pipeline, they are two different records that deserve two different answers, and the only honest fix is a three-way if() that names each one.

The habit to carry out of this section: before you type /, ask what the bottom can be. Not whether it can be blank. What it can be. For most CRM divisors the answer includes zero, and zero is the one you have to handle.

Subtraction and the variance column

- is the quiet one. Its best use in a CRM is not arithmetic for its own sake, it is measuring the gap between what you expected and what happened.

{Actual spend} - {Approved budget}
{Closed value} - {Forecast value}

Positive means over, negative means under, and the sign is information. Keep it.

Pair it with abs() only when you genuinely want the size of the error rather than its direction:

abs({Closed value} - {Forecast value})

Those two columns answer different questions and a team usually wants both. The signed one tells you whether your forecasts lean optimistic or pessimistic — which is a coachable, fixable pattern. The absolute one tells you how noisy they are, which is a different problem with a different fix. Average the signed column across a rep's deals and you have their bias; average the absolute column and you have their accuracy. A rep who is wildly wrong in both directions and a rep who is consistently 20% optimistic look identical on one of those columns and nothing alike on the other.

Both inherit the blank behaviour, so guard both inputs where either can be missing — and note that ?? 0 on a *forecast* is a strong claim. It says a deal nobody forecast was forecast at zero, which will flatter or damn the rep depending on which way the deal went. A blank forecast usually means the deal should be excluded, not scored, and that is an if() not a fallback.

Subtracting one date from another is not how you get days

A reasonable instinct, especially from a spreadsheet background:

{Renewal date} - today()

Do not build on it. The subtraction operator is documented with exactly one example, 100 - 50 = 50, and nothing on the functions page says the operator accepts dates or what unit it would return if it did. Meanwhile Attio ships a documented function that does this job properly and lets you name the unit:

dateDiff(today(), {Renewal date}, "days")

dateDiff() is the route. Use it. The one thing to remember, because it costs people a column: color:var(--color-text-heading)]">dateDiff returns the absolute difference, so it cannot tell you whether a renewal is thirty days away or thirty days past. For direction you need a comparison, which is the pairing the [comparison operators article ends on:

if({Renewal date} < today(), 0 - dateDiff(today(), {Renewal date}, "days"), dateDiff(today(), {Renewal date}, "days"))

Note the 0 - at the front of the overdue branch. That is subtraction doing something the function library does not offer — there is no negate() — and it is the cleanest way to flip a sign in Attio. Overdue renewals come back negative, upcoming ones positive, and the column sorts into a genuine priority list with the most overdue account at the top.

To move a date rather than measure one, the operator is still the wrong tool. dateAdd() handles that, and remember to inline the base expression rather than pointing at another formula attribute.

Precedence is real and parentheses are free

Attio's operators follow the precedence you would expect: multiplication and division bind tighter than addition and subtraction. So these are two different formulas:

{Base fee} + {Seats} * {Price per seat}
({Base fee} + {Seats}) * {Price per seat}

The first multiplies the seats by the price and then adds the base fee, which is almost certainly what you meant. The second multiplies the base fee by the price per seat, which is nothing anybody meant.

The and() and or() article makes the same case for boolean operators, and the advice is identical here: parenthesize anything mixed, even when the precedence is on your side. You are not writing the parentheses for the engine. You are writing them for the person who opens this attribute in eight months, which is frequently you, and who should not have to remember a precedence table to know whether the formula is correct.

This matters more in Attio than in a spreadsheet for a mundane reason: formula attributes are long. By the time you have guarded three inputs with ??, the arithmetic is buried inside parentheses that exist for other reasons, and the shape of the calculation stops being obvious. Group the maths explicitly and it stays readable.

The entire documented error surface of the language is one sentence

Something I only noticed by searching for it, and it reframes how much weight the docs can carry.

Search Attio's entire formula functions library page for the word "error". It appears once. Not once per section — once, in total, on the whole page. It is in the description of number(): "Converts a text or numeric value to a number. If the value can't be parsed as a number, the formula returns an error."

That is the complete documented error behaviour of the formula language. One function, one sentence.

Everything else is described in terms of what it returns when it works. splitPart() "returns empty when there is no part at that index". left() and right() return "the whole value" when the count is too large and "empty text" at zero or less. checkbox(): "Empty values stay empty." rating() clamps out-of-range numbers "to the nearest valid value." text(): "empty values stay empty." The library's consistent design preference is clear and it is a good one: return something sensible rather than fail.

Which tells you roughly what to expect from {Deal value} / 0 — most likely an empty cell or a refusal to save, rather than a crash or a cascade. But *expect* is the operative word. The documentation does not say, and an empty revenue cell is the specific failure this series keeps warning about, because an empty cell in a money column reads as zero and nobody investigates a zero.

The practical lesson is not about division. It is about how much you can learn from this page at all. The docs tell you what every function returns on success and almost nothing about what any of them do on bad input. Every one of the series' sharpest findings has come from the gap between those two things. Build the edge case, look at the column, write down what you saw.

Rounding is a separate decision from the arithmetic

Division produces decimals, and money with four decimal places in it looks broken.

{ARR} / 12

An ARR of 100,000 gives you 8333.333333. Correct, and not something you want in a column a customer might see. The arithmetic and the presentation are two decisions:

round({ARR} / 12, 2)

round(), ceil() and floor() each make a different claim, and on money the difference has a direction. round() is for display. ceil() is for anything you bill, because rounding a billable unit down is a discount you did not agree to. floor() is for anything you have earned, like whole months of tenure.

One thing worth keeping in mind: rounding on the way *in* to further arithmetic compounds. If you round a monthly figure to two places and then multiply by twelve to get back to an annual one, you will not land exactly where you started. Round once, at the end, on the column a human reads — and keep the unrounded expression inline in any formula that feeds another calculation, rather than chaining to a rounded formula attribute. The series has a standing warning about referencing another formula attribute at all: it returns wrong values with no error. Inline the base expression.

Percentages have no operator and no type

There is no % operator in Attio, and there is no percentage type in the arithmetic. A percentage is multiplication by a decimal, and the only thing that makes a column read as a percentage is the name you give the attribute.

Going from a stored percentage to a multiplier:

{Deal value} * ({Discount percent} / 100)

Going the other way, from two amounts to a rate:

({Closed value} / {Deal value}) * 100

That second one is a division with an attribute on the bottom, so it is exactly the case from earlier: a {Deal value} of zero or blank has no answer, and the expression needs a guard before it goes anywhere near a report.

if(({Deal value} ?? 0) == 0, null, ({Closed value} / {Deal value}) * 100)

The discipline that saves the most trouble here is boring: decide once whether your percentage attributes store 15 or 0.15, write it down, and never mix the two in the same workspace. A formula that divides by 100 a value already stored as 0.15 produces 0.0015, which is a real number, renders fine, and is wrong by a factor of ten thousand. No comparison catches it and no error appears. That one is worth a line in your data dictionary.

When a function beats an operator

Three cases where the operator is not the tool, despite looking like it.

color:var(--color-text-heading)]">Across associated records, use the aggregations. + works on two values in one record. Totalling a numeric attribute across every linked record is [sum(), and Attio's own FAQ confirms the aggregations work across relationships — count({Associated people > Record ID}) counts linked people. The same FAQ carries a limitation worth knowing: you cannot filter an aggregation. The documented workaround is pure arithmetic thinking — "create a formula attribute on the associated record that returns the value when a condition is met and 0 otherwise, then sum that formula." A conditional zero is how you filter a sum in Attio.

For exponents and remainders, the functions are the only route. power(base, exponent) and mod(dividend, divisor). Both are documented without any mention of currency, incidentally, unlike their twelve neighbours in the same table — read that as a hint that these two expect plain numbers.

To get a number out of text, use number(). The arithmetic operators will not coerce a text value for you. number() is the documented conversion, and it is the one function in the library whose failure is documented: unparseable input returns an error rather than an empty cell. That makes it the one place where a bad value is loud instead of quiet, which is a feature. Guard it the same way you guard anything else, and expect an error rather than a blank when the text is junk.

CRM use cases that earn their keep

  • Commission per deal. {Deal value} * 0.15, output type Currency. If rates vary, move the rate onto the record and the formula becomes a policy rather than a number.
  • Weighted pipeline. {Deal value} * ({Probability} / 100), then sum() it across the board for a forecast that does not require anyone to believe every deal closes.
  • Price per seat, guarded. if(({Seats} ?? 0) == 0, null, {Deal value} / {Seats}). The guard is the whole point.
  • Monthly from annual. round({ARR} / 12, 2). Two decimals, because it is money.
  • Forecast bias and forecast accuracy as two columns. {Closed value} - {Forecast value} and abs({Closed value} - {Forecast value}). The first is coachable, the second is a reliability measure, and a team wants both.
  • Total contract value from parts. ({Base fee} ?? 0) + ({Onboarding fee} ?? 0) + ({Support fee} ?? 0). Guard every input or one blank empties the column.
  • The reconciliation check. {ARR} != {MRR} * 12 finds every record where somebody updated one figure and not the other. Set the output type to Checkbox and filter on it.
  • Signed days to renewal. if({Renewal date} < today(), 0 - dateDiff(today(), {Renewal date}, "days"), dateDiff(today(), {Renewal date}, "days")). Subtraction supplies the sign the library has no function for.
  • Effective discount. if(({List price} ?? 0) == 0, null, (({List price} - {Closed value}) / {List price}) * 100). One subtraction, one guarded division, one multiplication, and a number sales leadership will actually look at.

Copy-paste formulas

Commission, money type restored if your workspace needs it:

currency({Deal value} * 0.15)

Weighted pipeline value:

{Deal value} * ({Probability} / 100)

Total contract value with every input guarded:

({Base fee} ?? 0) + ({Onboarding fee} ?? 0) + ({Support fee} ?? 0)

Monthly revenue from annual, rounded for humans:

round({ARR} / 12, 2)

Price per seat, safe against both a blank and a zero:

if(({Seats} ?? 0) == 0, null, {Deal value} / {Seats})

Seat revenue that refuses to pretend an unknown seat count is zero:

if({Seats} == null, null, {Seats} * {Price per seat})

Forecast bias, signed:

{Closed value} - {Forecast value}

Forecast accuracy, unsigned:

abs({Closed value} - {Forecast value})

Effective discount percentage, guarded:

if(({List price} ?? 0) == 0, null, (({List price} - {Closed value}) / {List price}) * 100)

Annual and monthly figures disagree, as a checkbox:

{ARR} != {MRR} * 12

Signed days to renewal, negative when overdue:

if({Renewal date} < today(), 0 - dateDiff(today(), {Renewal date}, "days"), dateDiff(today(), {Renewal date}, "days"))

Flip the sign of any number, since there is no negate function:

0 - {Variance}

Gotchas

  • Never put a ?? 0 fallback on a divisor. It converts a harmless blank into an undefined expression. Guard the divisor with an if() instead, and test for zero at the same time: if(({Seats} ?? 0) == 0, null, {Deal value} / {Seats}).
  • Before you type /, ask what the bottom can be. Not just whether it can be blank — whether it can be zero. In CRM data it usually can.
  • A blank empties the whole expression. One missing fee blanks the total. Guard each input with ?? 0, bound to its own attribute in its own parentheses.
  • A guarded zero can be worse than a blank. ({Seats} ?? 0) * {Price per seat} returns a confident 0 for a record whose seats were never imported. Where a zero would be a lie, use an if() and let the cell stay empty.
  • The currency return type of the operators is undocumented. Build the formula, set the output type explicitly, look at the column, and reach for currency() only if the money type did not survive.
  • currency() sets the type, never the unit. One argument, no currency code. The currency comes from the attribute's configuration.
  • color:var(--color-text-heading)]">There is no exponent or remainder operator. Use [power() and mod().
  • There is no percentage operator and no negate function. Divide by 100 yourself, and flip a sign with 0 - {value}.
  • color:var(--color-text-heading)]">Do not subtract dates. Use [dateDiff(), which names its unit, and remember it returns the absolute difference.
  • Parenthesize mixed arithmetic. {Base} + {Seats} * {Price} is not ({Base} + {Seats}) * {Price}. Write the parentheses for the next reader.
  • Round once, at the end. Rounding mid-chain compounds, and chaining to a rounded formula attribute is unreliable in Attio regardless. Inline the base expression.
  • Pick one percentage convention per workspace. Mixing 15 and 0.15 produces wrong answers that look like right ones.

Final thoughts

The four arithmetic operators are the least interesting symbols in the language and the ones most likely to be wrong in a way nobody notices, because their failures all produce numbers rather than errors. An empty revenue cell reads as zero. A plain number where money was expected renders almost right. A guarded divisor turns a missing value into an undefined one. None of that announces itself.

Two habits cover nearly all of it, and they pull in opposite directions, which is why the series took thirty-nine articles to notice the second one.

Guard every input that can be blank, because a single null empties the whole expression — and then ask, for each guard, whether the value you chose is honest in that position. ?? 0 means "this contributes nothing", which is true on the top of a fraction and catastrophic on the bottom.

The broader point is the one worth keeping. The documentation tells you what every function returns when it works, and almost nothing about what any of them do when the input is wrong — the word "error" appears exactly once on the entire page. Every useful thing this series has published came out of that gap. Build the edge case on purpose, look at what the column does, and write it down, because the docs are not going to and the next person to need the answer is on your team.

For the rest of the library — every logic, history, math, date, text and type conversion function with CRM use cases — see the complete guide to Attio formula attributes.

Next: the ! operator, the last symbol in the table and the only one that takes a single argument.

And if you would rather have your revenue maths, commission fields and forecast columns built correctly the first time, that is literally what we do. Get a free workspace audit or see the AI-native Attio sprint.

Official sources

Attio documentation used to verify this guide:

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.

Ready when you are.

Book a call and we will tell you honestly whether this is worth doing, or start with the free 48-hour audit and decide afterwards.