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

Attio formula attributes: the complete guide to calculated fields

Written by

Published 37 min readTested in live Attio workspaces
Contents13 sections
  1. What are formula attributes?
  2. Availability and permissions
  3. Creating your first formula
  4. Output types
  5. Let AI write the formula for you
  6. Referencing attributes and relationships
  7. The full function library
  8. How recalculation works
  9. Handling empty values
  10. Real-world formulas you can steal
  11. Limitations to know about
  12. Formula attributes vs. workflows vs. AI attributes
  13. Final thoughts

Attio has finally shipped one of its most requested features: formula attributes. Your CRM can now do the math for you — weighted pipeline, days in stage, lead scores, renewal countdowns — all computed live, right inside your records, with no workflows, no spreadsheet exports, and no Zapier duct tape.

We've been testing formula attributes across client workspaces at Craftt, and this is our full breakdown: what they are, how they work, every function in the library with a real CRM use case, formulas you can copy today, and the limitations you should know about before you build.

color:var(--color-text-heading)]">Prefer to watch? Every function below gets its own short video in the [Every Attio formula function playlist. 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 are formula attributes?

A formula attribute is a new attribute type you can add to any object or list in Attio. Instead of storing a value someone typed in, it derives its value from other data on the record — and recalculates automatically whenever that data changes.

Think of it as a spreadsheet formula that lives inside your CRM, with three properties Attio has been deliberate about:

  • Deterministic. The same inputs always produce the same output. No AI guesswork in the result — which means you can forecast, report, and audit on top of formulas with confidence.
  • Composable. Formulas can reference other attributes *and other formulas*. Set your stage probabilities once, then reuse them in weighted pipeline, reports, and scoring models.
  • Reactive. When an underlying value changes, the formula recalculates within seconds. No refresh button, no nightly sync.

Because the computed value is a first-class attribute, it flows everywhere attributes do: you can filter and sort views by it, build reports on it, trigger workflows from it, and personalize sequences with it.

Availability and permissions

  • Formula attributes are available on Pro and Enterprise plans.
  • Only Admins and Members with Full access can create and manage them.
  • AI-generated formulas don't consume workspace AI credits — describe formulas in plain language as much as you like.

Creating your first formula

The flow is the same as any other attribute:

  1. Open the object or list settings and click to add a new attribute.
  2. Choose Formula as the attribute type.
  3. The formula editor opens — a proper editor, not a one-line input.

Inside the editor you get:

  • Autocomplete as you type — a searchable list of every function and attribute, with inline syntax guidance and examples.
  • Attribute references — type { to pull up every attribute on the object.
  • A record picker to preview the result against real records before you save. This is the underrated part: you see exactly what the formula returns for actual data, not a theoretical example.
  • Error highlighting with AI-assisted fixes, so a broken formula never silently ships.
  • Undo/redo, so you can experiment freely.

Output types

By default, the output type is set to Auto — Attio infers it from whatever your formula returns. But you can override it via the Output dropdown and force the result into a specific type: Text, Number, Checkbox, Date, Timestamp, Rating, Currency, or Phone number.

This matters more than it looks. Forcing a number output to Currency gets you proper formatting in views and reports. Forcing a boolean to Checkbox gives you a clean filterable tick. And a 1–5 score rendered as a Rating attribute reads instantly in a list view. For numeric outputs you can also control decimal places and number grouping.

Let AI write the formula for you

You don't have to learn the syntax at all. Below the editor sits a prompt box — *"Describe the formula you want Attio to create..."* — and it does exactly what it says.

Type something like *"Calculate the number of days this deal has been in its current stage"* and Attio writes the formula for you. If the result isn't quite right, there's a Refine your formula input for iterating: "actually, round it to whole days" or "return 'Stale' if it's more than 14 days." You converse your way to the formula.

Two things we appreciate about how this is built:

  • It doesn't burn workspace credits, so there's no penalty for iterating.
  • The AI produces a *formula* — a deterministic, inspectable expression — not an AI-generated value. You get the convenience of natural language with none of the unpredictability.

For most teams, this is the way to start: describe what you want, read what the AI wrote, and use it as a syntax tutorial.

Referencing attributes and relationships

Type { in the editor and every attribute on your object appears — from Record ID, Domains, Name and Description to enriched data like Twitter follower count, Estimated ARR, Funding raised, Foundation date and Employee range, plus interaction data like First interaction and Last interaction.

And here's the powerful part: if your object connects to other objects, relationship attributes are available too. A formula on a Company can reach into its related records — count the Team members linked to it, work with the deals attached to it, and so on. Since Attio's whole data model is built on relationships between objects, formulas inherit that graph.

A tiny example that's impossible in most CRMs without a report:

count({Team})

That's it — a live headcount of linked People on every Company record, sortable and filterable in any view.

The full function library

Attio ships a full library of operators and functions across six categories. Here's every one of them — what it does, and a concrete CRM use for it.

Operators

OperatorSyntaxWhat it doesExample
+a + bAddition100 + 50 → 150
-a - bSubtraction100 - 50 → 50
*a * bMultiplication. Whether the money type survives arithmetic on a Currency attribute is color:var(--color-text-heading)]">not documented — the docs state return types for sum()/avg() and nothing for the operators ([deep dive)100 * 2 → 200
/a / bDivision. The only operator with an input that has no answer: a divisor of 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">0 is undefined and undocumented, and a ?? 0 guard on the divisor creates that case rather than preventing it ([deep dive)100 / 4 → 25
==a == bEqual to. On a select or status attribute this matches the option's color:var(--color-text-heading)]">title, so renaming a stage silently turns it false ([deep dive)100 == 100 → true
!=a != bNot equal to. A single operator, color:var(--color-text-heading)]">not ! applied to == — and !(a == b) is a different expression on a blank ([deep dive)100 != 50 → true
>a > bGreater than. An empty left side makes the result color:var(--color-text-heading)]">empty, not false, so the record drops out of both sides of your filters ([deep dive)100 > 50 → true
>=a >= bGreater than or equal to100 >= 100 → true
<a < bLess than50 < 100 → true
<=a <= bLess than or equal to50 <= 100 → true
!!valueNegates a boolean. Documented over two states and almost always pointed at a three-state expression: negating a comparison that came back empty does color:var(--color-text-heading)]">not give you the complement ([deep dive)!true → false
??left ?? rightNull coalescing — fallback when the left side is empty{ARR} ?? 0

The 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">?? operator deserves a special mention — it's how you keep formulas from breaking on incomplete data. More on that in [Handling empty values, and in our full deep dive: the ?? operator in Attio.

The six comparison operators carry more weight than their one-line descriptions suggest — every flag, threshold and overdue field in a workspace rests on them, and their behaviour on text, dates, stage names and blanks is undocumented. See the comparison operators in Attio for what each one does to real data, why a blank is a third outcome rather than a false, and why the guard belongs on the left side of the comparison rather than on its result.

The four arithmetic operators are documented in one word each, and the gap is about color:var(--color-text-heading)]">types: the Math functions section names "currency value" in twelve separate descriptions and the operator table never mentions currency at all, so the product documents what log() does to money and not what / does to it. See [the arithmetic operators in Attio for how to settle the currency question in your own workspace in ten seconds, why a ?? 0 fallback is correct on everything you add or multiply and a bug on a divisor, and why dateDiff() rather than - is the way to subtract dates.

The last symbol in the table, 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">!, is the one that looks safest and is not. Attio documents negation twice — once as an operator and once as not() — and the longer description enumerates a domain of exactly two values ("Converts true to false and false to true"), while the expressions people negate in a CRM have three. See [the ! operator in Attio for why !A is the rest of the records *that had an answer* rather than the complement of A, why an Attio checkbox is a three-state field by the product's own documentation, and the one comparison that is safe to negate without a guard.

CRM use cases:

  • + — {Base fee} + {Onboarding fee} — total contract components on the record itself, so reps never re-add line items in a spreadsheet.
  • - — {Contract value} - {Discount amount} — net revenue after concessions, the number finance actually wants to see.
  • * — {Seats} * {Price per seat} — quote math that can't drift out of sync with the inputs. Guard a blank with care: ({Seats} ?? 0) * {Price per seat} returns a confident 0 for a record whose seat count was never imported, which is less visible than the blank it replaced.
  • / — {Deal value} / {Seats} — effective price per seat, which instantly exposes over-discounted deals. Ask what the bottom can be before you ship it: if(({Seats} ?? 0) == 0, null, {Deal value} / {Seats}) handles the blank and the zero in one test.
  • == — {Plan} == "Enterprise" — the building block for tiered logic: route enterprise accounts to a different playbook. On text written by humans or integrations, normalise first — lower({Plan} ?? "") == "enterprise" behaves the same whether == is case-sensitive or not, which the docs never say.
  • != — {Billing country} != "US" — flag international deals that need tax or legal review before signature.
  • > — {Estimated ARR} > 100000 — segment true enterprise accounts using enriched data, not gut feel.
  • >= — {Employee count} >= 200 — encode your ICP threshold once instead of every rep rebuilding the filter.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">< — {Days to renewal} < 60 — the renewal risk window that feeds your CS team's queue. A comparison is also the only thing that tells you *which side* of a date you are on, since [dateDiff() returns the absolute difference and cannot distinguish 30 days before a renewal from 30 days after.
  • <= — {Lead score} <= 20 — the cutoff below which leads go to nurture instead of a rep.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">! — !({Is customer} ?? false) — target prospects only, and keep existing customers out of cold sequences. The ?? false is not optional: an Attio checkbox that has never been set is empty rather than false, so the unguarded !{Is customer} silently drops every record nobody has classified ([deep dive).
  • ?? — {MRR} ?? 0 — one blank field can't blank out a whole rollup or forecast column.

Logic functions

FunctionSyntaxWhat it doesExample
if()if(condition, trueValue, falseValue)Returns trueValue when the condition is true, otherwise falseValueif({Deal value} > 10000, "Enterprise", "Standard")
and()and(left, right) or left and rightTrue only when both arguments are trueand({Deal value} > 10000, {Deal stage} == "Negotiation")
or()or(left, right) or left or rightTrue when at least one argument is trueor({Deal stage} == "Closed won", {Deal stage} == "Closed lost")
not()not(value) or !valueReturns the opposite boolean. The same operation in two syntaxes; guard the value first, because a negation of an empty value is not 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">true ([deep dive)not(hasBeenIn({Deal stage}, "Closed lost"))
ifNull()ifNull(left, right)Returns the left value when it is not null, otherwise the right value. Exactly two arguments — the function form of the ?? operatorifNull({ARR}, 0)

13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">if() is the workhorse — nest it to build tiered logic like lead scores and pricing bands. We wrote a full deep dive on it: [the if() function in Attio formulas. Both and/or support keyword syntax (a and b) as well as function syntax, so formulas read naturally.

CRM use cases:

  • if() — if({Deal value} > 50000, "High touch", "Standard") — turns raw numbers into labels reps can filter, sort, and act on without interpreting data themselves.
  • and() — and({Employee range} >= 200, {Purchase timeline} == "This quarter") — a single "Sales qualified" flag instead of a multi-condition saved filter every rep rebuilds slightly differently.
  • or() — or({Stage} == "Closed won", {Stage} == "Closed lost") — one "Closed" bucket for clean win/loss reporting regardless of outcome.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">not() — not(hasBeenIn({Deal stage}, "Closed lost")) — keep previously lost deals out of automated re-engagement sequences, where a tone-deaf email does real damage. Full deep dive on the logic trio: [the and(), or(), and not() functions in Attio, and on negation specifically: the ! operator in Attio.

Math functions

FunctionSyntaxWhat it doesExample
abs()abs(value)Absolute valueabs(-42.5) → 42.5
round()round(value, num_digits)Rounds to the given decimal placesround(1234.5678, 2) → 1234.57
ceil()ceil(value)Rounds up to the nearest integerceil(7.2) → 8
floor()floor(value)Rounds down to the nearest integerfloor(9.8) → 9
sum()sum(values)Sum of an arraysum([10, 25, 3]) → 38
count()count(values)Number of items in an arraycount([10, 25, 3]) → 3
min()min(values)Smallest value in an array (numbers, currency, or dates)min([10, 25, 3]) → 3
max()max(values)Largest value in an array (numbers, currency, or dates)max([10, 25, 3]) → 25
avg()avg(values)Average of an arrayavg([1.5, 2.3, 4.7]) → 2.8333
mean()mean(values)Arithmetic mean (numbers, currency, or dates)mean([10, 20, 90]) → 40
median()median(values)Middle value (numbers, currency, or dates)median([10, 20, 90]) → 20
unique()unique(values)Removes duplicates from an arrayunique([1, 2, 2, 3, 3]) → [1, 2, 3]
power()power(base, exponent)Base raised to an exponentpower(2, 8) → 256
mod()mod(dividend, divisor)Remainder after divisionmod(10, 3) → 1
exp()exp(value)e raised to the given powerexp(2.5) → 12.1825
log()log(value)Natural logarithmlog(1000) → 6.9078
random()random()Random number between 0 and 1 — takes no arguments, and unlike 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">now()/today() its recalculation is undocumented, so it can never be a stable field ([deep dive)floor(random() * 100)

Note that the array functions (sum, count, avg, min, max, median, unique) pair naturally with multi-value and relationship attributes — that's how count({Team}) works.

CRM use cases:

  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">abs() — abs({Forecast} - {Actual}) — forecast variance regardless of direction, for spotting reps who consistently miss either way. Full deep dive: [the abs() function in Attio.
  • round() — round({Deal value} * 0.4, 0) — clean weighted-pipeline figures that don't show $19,847.3333 in a board deck.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">ceil() — ceil({Users} / 10) — seat bundles needed when you sell in packs of 10; you never sell 4.2 bundles. Full deep dive on round/ceil/floor: [the round(), ceil(), and floor() functions in Attio.
  • floor() — floor(dateDiff({Signup date}, today(), "months")) — full months as a customer, for tenure-based segmentation.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">sum() — total pipeline per company across its linked deals, live on the record instead of buried in a report. Full deep dive: [the sum() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">count() — count({Team}) — contacts per account: fewer than 2 means the deal is single-threaded, your biggest silent risk. Full deep dive: [the count() function in Attio.
  • min() — earliest renewal date among an account's linked contracts — the real "next action" date for CS.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">max() — the largest deal in an account's history — anchors expansion conversations and upsell targets. Full deep dive on both: [the min() and max() functions in Attio.
  • avg() / mean() — average deal size across an account's closed deals — flags accounts buying below their potential.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">median() — median deal size for a segment — one whale deal can't distort what "typical" looks like. Full deep dive on avg/mean/median: [the avg() and median() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">unique() — distinct products per account across its deals, the cleanest cross-sell whitespace metric. Full deep dive: [the unique() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">power() — {ARR} * power(1.2, 3) — three-year value projection at 20% expansion, useful for account prioritization; fractional exponents also give you roots and a real CAGR. Full deep dive: [the power() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">mod() — mod({Account number}, 4) — deterministic bucketing into 4 groups for territory splits or experiment cohorts, and the only route to a weekday. Full deep dive: [the mod() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">exp() / log() — log({Twitter follower count} + 1) — tame heavy-tailed enrichment metrics so one celebrity account doesn't dominate a lead score. Note that log() is the natural log, not base 10. Full deep dive on both: [the exp() and log() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">random() — floor(random() * 100) — the only function in the library that returns a different answer on every recalculation, which means a sample or cohort built on it reshuffles and can never be finished. For a fixed 10% audit slice use a stable key instead: mod(dateDiff(timestamp("2020-01-01T00:00:00Z"), {Created at}, "seconds"), 10) == 0. Note that Attio's documented round(random() * 100, 0) makes 0 and 100 half as likely as every other value, so use floor() for buckets. Full deep dive: [the random() function in Attio.

Date and time functions

FunctionSyntaxWhat it doesExample
now()now()Current date and time as a timestamp; adds a daily recalculation around midnight UTCdateAdd(now(), 7, "days")
today()today()Current date, without time; adds a daily recalculation around midnight UTCtoday()
date()date(raw_date)Parses text/number (ISO 8601 or numeric timestamp) into a datedate("2024-01-15")
timestamp()timestamp(value)Parses text/number (ISO 8601 or Unix timestamp) into a timestamptimestamp("2024-01-15T09:30:00Z")
dateAdd()dateAdd(date, units, unit_type)Adds time units ("days", "months", "years"…) to a datedateAdd(date("2024-06-01"), 3, "months") → 2024-09-01
dateDiff()dateDiff(start, end, unit_type)Absolute difference between two dates; units from "seconds" up to "years"dateDiff(date("2024-01-01"), date("2024-01-31"), "days") → 30
formatDate()formatDate(date, format)Formats a date as text; tokens are case-sensitive — yyyy, MM, dd, MMMM, Q, HH:mm — and literal letters are escaped in single quotesformatDate({Close date}, "MMMM yyyy") → March 2026
day()day(date)Day of month (1–31)day(date("2024-03-15")) → 15
hour()hour(date)Hour of day (0–23)hour(timestamp("2024-03-15T14:30:00Z")) → 14
year()year(date)Year as a four-digit number — a timestamp extracts in UTC, so wrap it in 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">setTimezone() before year-end reporting ([deep dive)year(date("2024-03-15")) → 2024
quarter()quarter(date)color:var(--color-text-heading)]">Calendar quarter as a number from 1 to 4 — there is no fiscal option; rebuild fiscal quarters from month() ([deep dive)quarter(date("2024-03-15")) → 1
month()month(date)Month as a number from 1 to 12 — every January is 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">1, so grouping by it merges years unless you build a composite key ([deep dive)month(date("2024-03-15")) → 3
minute()minute(date)Minutes as a number from 0 to 59 — useful only as hour() * 60 + minute(), a minutes-since-midnight keyminute(timestamp("2024-03-15T14:30:00Z")) → 30
second()second(date)Seconds as a number from 0 to 59second(timestamp("2024-03-15T14:30:45Z")) → 45
eomonth()eomonth(date)Last day of the montheomonth(date("2024-02-10")) → 2024-02-29
setTimezone()setTimezone(date, timezone)Converts a date or timestamp to the specified timezone, returning a timestamp. Use IANA zone names, not offsets — deep divesetTimezone(now(), "Europe/London")

Formulas using now() or today() recalculate daily, so "days since last interaction" style fields stay current without anyone touching the record.

CRM use cases:

  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">today() — dateDiff({Last interaction}, today(), "days") — days of silence on every account; the single best "who's going cold" signal. Full deep dive on both clock functions: [the today() and now() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">now() — dateDiff({Created at}, now(), "hours") — lead age in hours, for enforcing speed-to-lead SLAs where minutes decide conversion. Note that now() recalculates daily rather than continuously — see [the today() and now() deep dive.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">date() — date({Imported close date}) — turn text dates from a migration or CSV import into real, sortable, filterable dates. A text attribute holding a date supports no relative filters, no grouping by month or quarter, and no date arithmetic, and ISO strings sort correctly by accident so the problem hides. Full deep dive on both parsers: [the date() and timestamp() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">timestamp() — timestamp({Raw created at}) — parse datetime strings and Unix epoch numbers pushed in by integrations, so webhook data behaves like native Attio timestamps. Watch the unit: ten digits is seconds, thirteen is milliseconds. Use date() for calendar labels and timestamp() only for real moments — see [the date() and timestamp() deep dive.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">dateAdd() — dateAdd({Close date}, 12, "months") — auto-computed renewal date the moment a deal closes; nobody ever forgets to set it. Full deep dive: [the dateAdd() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">dateDiff() — dateDiff({Created at}, {Close date}, "days") — sales cycle length per deal, the raw material for velocity reporting. Full deep dive: [the dateDiff() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">formatDate() — formatDate({Close date}, "'Q'Q yyyy") — quarter labels like "Q1 2026" you can group and pivot on in views and reports. Note the single quotes: every letter is a token, so the literal Q has to be escaped, and bracket escaping from other date libraries does not work. Full deep dive: [the formatDate() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">day() — day({Contract start}) — billing day of month, for aligning invoicing and dunning workflows. Full deep dive on both extractors: [the day() and hour() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">hour() — hour({Created at}) — what hour inbound leads arrive; the case for (or against) after-hours routing coverage. Note that hour() reads the timestamp in UTC — see [the day() and hour() deep dive.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">quarter() / year() — quarter({Close date}) — the quarter as a *number* you can compare and filter on, where formatDate({Close date}, "yyyy 'Q'Q") gives you the text label for a report axis. Different jobs; use both — and note the label has to lead with the year, because "'Q'Q yyyy" sorts every Q1 in history before every Q2. quarter() is calendar-only, so an April or October fiscal year needs ceil((mod(month({Close date}) - 4 + 12, 12) + 1) / 3) instead. Full deep dive: [year(), quarter() and month() in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">eomonth() — eomonth({Contract start}) — align renewals and invoices to month-end without manual date arithmetic. Full deep dive: [the eomonth() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">setTimezone() — setTimezone({Next meeting}, "America/New_York") — meeting times in the prospect's timezone, killing the "wait, whose 3pm?" problem. Its bigger job is report boundaries: raw timestamps are carried in UTC, so hour(), day(), quarter() and year() bucket everything by the UTC calendar unless you convert first — quarter(setTimezone({Closed on}, "America/New_York")). It changes buckets, never durations: dateDiff() is unaffected. See the [setTimezone() deep dive.

Text functions

FunctionSyntaxWhat it doesExample
contains()contains(haystack, needle)Checks whether text contains a value (or an array contains an element)contains("Hello World", "World") → true
replace()replace(subject, search, replacement)Replaces the first occurrencereplace("Hello World", "World", "There") → "Hello There"
replaceAll()replaceAll(subject, search, replacement)Replaces every occurrencereplaceAll("Hello World", "l", "r") → "Herro Worrd"
length()length(value)Length of a text valuelength("Hello World") → 11
split()split(value, delimiter)Splits text into a list of parts on a delimiter; index into it, because a formula can't return a listsplit("a,b,c", ",")[0] → "a"
splitPart()splitPart(value, delimiter, index)Splits on a delimiter and returns one part, counting from zerosplitPart("sarah@attio.com", "@", 1) → "attio.com"
startsWith()startsWith(value, search)True when text begins with the search term; case-sensitivestartsWith("Hello World", "Hello") → true
endsWith()endsWith(value, search)True when text ends with the search term; case-sensitiveendsWith("report.pdf", ".pdf") → true
lower()lower(value)Converts text to lowercaselower("Hello World") → "hello world"
upper()upper(value)Converts text to uppercaseupper("Hello World") → "HELLO WORLD"
left()left(value, count)First count characters; returns the whole value if count exceeds the lengthleft("Hello World", 5) → "Hello"
right()right(value, count)Last count characters; returns the whole value if count exceeds the lengthright("Hello World", 5) → "World"

contains() doubles as a membership check on arrays, which makes it handy for multiselect attributes — "does Categories include SaaS?"

CRM use cases:

  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">contains() — contains({Categories}, "SaaS") — a filterable ICP-fit checkbox from a multiselect; also great for flagging competitor mentions in a notes field. Full deep dive: [the contains() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">replace() — replace({Domains}, "www.", "") — normalize domains for clean matching and dedupe across enrichment sources. Full deep dive on both: [the replace() and replaceAll() functions in Attio.
  • replaceAll() — replaceAll({Phone}, " ", "") — strip formatting noise so phone numbers match what your dialer expects.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">length() — if(length({Description}) < 50, "Needs research", "OK") — a data-quality flag showing which records are too thin to hand to a rep. Full deep dive: [the length() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">splitPart() — splitPart({Email addresses > Email address}[0], "@", 1) — the company domain on every person, which is the export-to-a-spreadsheet step most workspaces still run by hand. Note the [0]: email addresses are multi-value (text[]) and the text functions reject a list. Full deep dive on both: [the split() and splitPart() functions in Attio.
  • split() — count(split({Tags}, ",")) — how many values a CSV import jammed into one text field; the fastest map of what a migration flattened.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">startsWith() / endsWith() — endsWith(lower({Website}), ".gov") — classify records by naming convention, SKU prefix or domain suffix. Both are case-sensitive, so lower() first, and anchor on the label boundary: endsWith(domain, "acme.com") is also true for notacme.com. Full deep dive on both: [the startsWith() and endsWith() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">lower() / upper() — lower({Domains}[0]) == lower({Email domain}) — normalise case before comparing two fields, which is most of what dedupe matching actually is. Lower both sides, and put lower() innermost so replaceAll() and contains() inherit it. Full deep dive on both: [the lower() and upper() functions in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">left() / right() — left({SKU}, 3) — fixed-length prefixes and suffixes of codes and IDs, for grouping by product family or batch. Only reach for them when the format is genuinely fixed-width: on anything with a delimiter, such as a postcode outward code or an email domain, splitPart() is correct and a character count quietly cuts the wrong records. They are also how you rebuild the mid() Attio never shipped. Full deep dive on both: [the left() and right() functions in Attio.

Type conversion functions

These cast a value from one type to another — the repair kit for data that arrived in the wrong shape.

FunctionSyntaxWhat it doesExample
text()text(value)Converts a value to text. Every attribute type is supported, and empty values stay empty. On a record reference it returns the linked record's color:var(--color-text-heading)]">ID, not its name — [deep divetext({Deal value})
number()number(value)Converts a text or numeric value to a number. color:var(--color-text-heading)]">Returns an error if the value can't be parsed — the only function in the library that does, see the [number()/text() deep divenumber(splitPart({Raw date}, "/", 2))
currency()currency(value)Converts a number to a currency value. Sets the type, not the unit — there is no currency-code argument (deep dive)currency({Imported amount})
checkbox()checkbox(value)Converts a value to a checkbox. Non-empty or non-zero values become 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">true; empty values stay empty, so the result is three-valued ([deep dive)checkbox({Legacy flag})
rating()rating(value)Converts a number to a rating from 0 to 5. Numbers outside the range are clamped, silently and only upward (deep dive)rating({Score})
phoneNumber()phoneNumber(value)Converts text to a phone number. The text must include a country code, since E.164 is required — the only conversion in the library that rejects correct input (deep dive)phoneNumber({Imported phone})

13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">number() is the notable one, because it breaks the pattern the text functions set: where an out-of-range splitPart() index or a case-mismatched endsWith() fails silently, number() raises a visible error on anything it can't parse. That makes it a genuine data-quality probe rather than a quiet source of wrong answers. The flip side is that every other conversion in this table always succeeds, so when one of them is wrong it is wrong silently: checkbox() turns the text "false" into true because it tests for presence rather than truth, and rating() clamps a 1-10 score into a wall of fives. See [the number() and text() deep dive for the full failure table, and currency(), checkbox() and rating() for why the three that cannot fail are the ones to watch in a live view: each asserts something about your data that no function could verify, then renders it with a currency symbol, a ticked box or a rating.

CRM use cases:

  • number() — number({Imported deal value}) — recover numeric columns a CSV import landed as text, and get an error on exactly the rows that need a human.
  • text() — text({Deal value}) — make a numeric or select value usable by contains(), startsWith() and the rest of the text library.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">phoneNumber() — phoneNumber({Imported phone}) — turn imported phone strings into real phone numbers, which is also a fast test of which rows are missing a country code. Local-format numbers fail even though they are valid, and repairing them needs string concatenation, which the language has no operator for — replace("+44~", "~", {Phone raw}) builds one out of a literal template. Full deep dive: [the phoneNumber() function in Attio.
  • checkbox() — checkbox({Open pipeline}) — collapse a legacy 0/1 or populated/empty column into a real boolean you can filter on. Only safe over a number or currency: over imported text it returns true for every populated row, including the ones that say false. For a condition, write the comparison and set the output type to Checkbox instead — {Deal value} > 50000 says what makes the box true, and checkbox() cannot.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">currency() — currency(number(replaceAll(replaceAll({Raw amount}, "$", ""), ",", ""))) — the strip-parse-type chain that repairs an amount column a spreadsheet landed as text, with number() erroring on exactly the rows that still need a human. Walked through in [the number() and text() deep dive. The reason to bother: 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">sum() and avg() return the same type as their input, so a computed amount left as a plain number never aggregates or renders as money. It also cannot fix a mixed-currency column, because the function takes no currency code — see [currency(), checkbox() and rating().
  • rating() — rating(round({Score 1 to 10} / 2, 0)) — put a satisfaction or fit score into a rating attribute, rescaled first. Converting the raw score clamps everything from 5 upward to 5, which biases every average high and looks like good news rather than an artefact.

Note there is color:var(--color-text-heading)]">no concat() and no string-joining operator, so while you can convert anything to text, you cannot stick two pieces of text together inside a formula. That single gap is why a non-ISO date string can't be reassembled and parsed — see [the date() and timestamp() deep dive for the arithmetic workaround.

Attribute history functions

This is the category no spreadsheet can replicate, because it queries the history of an attribute, not just its current value. Attio has been tracking every change to your select and status attributes all along — these functions finally let you compute on that history.

FunctionSyntaxWhat it doesExample
hasBeenIn()hasBeenIn(attribute, values)True if the attribute is currently, or has ever been, set to the value. Accepts a single value or an array; optional match argument ("any" or "all") and a dateRange to limit the history checkedhasBeenIn({Deal stage}, ["Negotiation", "Signing"]) → true
timeSpentIn()timeSpentIn(attribute, value, unit)Total time the attribute spent at a value across its history. Pass the attribute itself as the value to target its current value. Units: "hours", "days", "weeks", "months" (months ≈ 30 days)timeSpentIn({Deal stage}, {Deal stage}, "days") → 14
valueSetAt()valueSetAt(attribute, value)Timestamp when the attribute was first set to a value. Pass the attribute itself as the value for when the current value was set; optional position argument ("first" or "last")valueSetAt({Deal stage}, {Deal stage}) → 2024-04-15
valueAt()valueAt(attribute, timestamp)The historical value at a specific point in timevalueAt({Deal stage}, date("2024-01-15")) → Proposal
previousValue()previousValue(attribute)The value immediately before the current one; empty when there's no prior history. Actor reference and interaction attributes aren't supportedpreviousValue({Deal stage}) → Proposal

Note: hasBeenIn(), valueSetAt() and timeSpentIn() only work with select and status attributes — they're not supported on text, number, date, or other attribute types.

Think about what this unlocks with zero engineering:

  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">timeSpentIn() — timeSpentIn({Deal stage}, "Negotiation", "days") — deal velocity: how long deals actually sit in each stage, and which ones are stuck right now. The metric every pipeline review needs and no CRM showed out of the box. Full deep dive: [the timeSpentIn() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">hasBeenIn() — hasBeenIn({Deal stage}, "Closed lost") — boomerang detection: which "active" deals were previously lost? They close differently and deserve different handling — and conversely, "ever reached Proposal" gives you true stage-conversion cohorts. Full deep dive: [the hasBeenIn() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">previousValue() — previousValue({Deal stage}) — regression alerts: a deal that just moved *backwards* is your loudest early-warning signal, and it's invisible in a normal stage column. Full deep dive: [the previousValue() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">valueSetAt() — valueSetAt({Status}, "MQL") — SLA measurement: the exact timestamp a lead became an MQL, so you can compute (and enforce) time-to-first-touch against it. Full deep dive: [the valueSetAt() function in Attio.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">valueAt() — valueAt({Deal stage}, date("2026-07-01")) — point-in-time snapshots: what stage was every deal in at quarter start? Pipeline-movement reporting without exporting a single row. Full deep dive: [the valueAt() function in Attio.

Historically this was the stuff of BI tools and change-data-capture pipelines. Now it's one attribute.

How recalculation works

A formula recalculates:

  • when it's first created,
  • when the formula itself is edited,
  • within seconds whenever any referenced value changes,
  • once daily (around midnight UTC) for formulas using now() or today().

There's nothing to schedule and no sync to babysit. If the inputs are right, the output is right.

Handling empty values

CRM data is never complete, and formulas need to survive that. Attio's rules:

  • An empty attribute evaluates to null.
  • Use the ?? fallback operator to substitute a default: {MRR} ?? 0 means "MRR, or zero if it's empty."
  • Explicitly check for emptiness with {attribute} == null. This is the one comparison a blank survives: every other comparison against an empty attribute returns empty rather than true or false.
  • Put the guard on the color:var(--color-text-heading)]">left side of a comparison, before it runs: ({Deal value} ?? 0) > 50000 decides what a missing value means, where ({Deal value} > 50000) ?? false only patches whatever fell out ([deep dive).

Rule of thumb: wrap every numeric input in a ?? 0 fallback (or handle null in an if()) before multiplying or summing, or a single blank field will blank out the whole result.

The one exception: color:var(--color-text-heading)]">never put ?? 0 on a divisor. A blank divisor would only have produced an empty result; the guard converts it into a division by zero, which is undefined. Guard a divisor with a condition instead — if(({Seats} ?? 0) == 0, null, {Deal value} / {Seats}) — where the ?? 0 sits on the left side of a comparison and the division never sees it ([deep dive).

Real-world formulas you can steal

Here are the patterns we're deploying in client workspaces, based on Attio's own examples and our builds.

1. Weighted pipeline value. Multiply deal value by the close probability of its stage:

{Deal value} * if({Stage} == "Negotiation", 0.4,
    if({Stage} == "Proposal", 0.2,
    if({Stage} == "Contract sent", 0.8, 0.1)))

A $50,000 deal in a 40% stage shows a $20,000 weighted value. Set output to Currency, sum the column in a pipeline view, and you have a live forecast.

2. Count linked records.

count({Team})

Live team size on every Company via the relationship attribute. Works for any relationship — deals per company, contacts per account, whatever your data model connects.

3. Stale deal alert.

if(timeSpentIn({Stage}, {Stage}, "days") > 14, "Stale", "Active")

Passing {Stage} again as the value targets whatever stage the deal is in right now. Filter a view to Stale and you have a standing triage list.

4. Days to renewal.

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

A live countdown on every account — recalculated daily thanks to today(). Filter for < 60 and your CS team has a renewal queue.

5. MRR to ARR.

({MRR} ?? 0) * 12

Trivial — and exactly the kind of trivial calculation that used to require a spreadsheet export.

6. Lead scoring.

if({Channel} == "Referral", 30, if({Channel} == "Inbound", 20, 10))
+ if({Employee range} == "200+", 25, 10)
+ if({Purchase timeline} == "This quarter", 30, 0)

Points per signal, summed into a single priority number. Set the output to Rating for an at-a-glance score, sort your lead view by it, and route from there.

7. Seat-based contract value.

{Seats} * {Price per seat}

Quote math that can never drift out of sync with the inputs.

Limitations to know about

Formula attributes are impressively complete for a v1, but plan around these:

  • Formula nesting is capped at three levels deep. A formula can reference other formula attributes, but the chain can only go so far — you can't drop in a formula attribute that's itself built on too many layers of other formulas. If formula A references B, which references C, that's your limit; a fourth layer won't be accepted. Design accordingly: keep base formulas (stage probability, normalized revenue) flat, and compose only one or two levels on top of them.
  • Pro and Enterprise only. Free and Plus workspaces don't get the attribute type.
  • Creation is limited to Admins and Full-access members — reasonable governance, but worth knowing before you promise your whole team they can build these.
  • now()/today() formulas refresh daily (around midnight UTC), not continuously. Fine for "days since" fields; don't expect minute-level countdown timers.
  • History functions only work on select and status attributes. hasBeenIn(), valueSetAt() and timeSpentIn() aren't supported on text, number, or date attributes, and previousValue() skips actor reference and interaction attributes. Structure your pipeline stages as statuses (which you should be doing anyway) to get full value.

Formula attributes vs. workflows vs. AI attributes

Attio now has three ways to populate a field automatically. Pick the right tool:

Formula attributesWorkflowsAI attributes
Best forDeterministic calculations from record dataMulti-step automation with side effects (Slack, email, record creation)Unstructured judgment — summarizing notes, classifying, enriching
RecalculatesAutomatically, seconds after inputs changeOn triggerOn configured runs
OutputAlways the same for the same inputsWhatever the workflow writesModel-generated, may vary
CostNo credits (including AI composer)Workflow runsAI credits

The old pattern of using workflow Calculation blocks to write computed values into fields is now effectively legacy for pure math — formulas are simpler, always current, and don't consume workflow runs. Save workflows for automation that *does* something, and AI attributes for questions with no formula-shaped answer.

Final thoughts

Formula attributes close one of the last real gaps between Attio and the "export it to a spreadsheet" workflow. The design choices are the right ones: deterministic results you can audit, an AI composer so nobody needs to memorize syntax, output types that keep data clean, and — the sleeper feature — history functions that turn your CRM's change log into queryable data.

If you build only one formula this week, make it timeSpentIn({Stage}, {Stage}, "days") on your deals. Stage velocity is the metric almost every team wants and almost no CRM shows out of the box. Now yours does.

Want formulas like these wired into your workspace — weighted pipeline, lead scoring, renewal countdowns, the lot? That's exactly the kind of build we do at Craftt. Start with a free workspace audit or see how the 7-day AI-native Attio sprint works.

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.