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

The unique() function in Attio: measuring breadth when every other number measures volume

Written by

Published 11 min read

Tested in live Attio workspaces.

Almost every number in a CRM measures volume. Deals closed, revenue booked, contacts attached, emails sent. Volume is easy to compute and easy to celebrate, and it hides the one thing account teams most need to know: whether all that activity is spread across the relationship or piled onto a single point of it.

unique() is the function that separates the two. It takes an array, throws away the repeats, and hands back what's actually distinct. On its own that's a housekeeping trick. Wrapped in count(), it turns into the metric that most workspaces are missing.

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(), and length().

Table of contents

What unique() does

One argument, one array back:

unique(values)

unique([1, 2, 2, 3, 3]) returns [1, 2, 3]. Duplicates removed, everything else left alone.

Note what it does not return: a number. unique() is a transformation, not an aggregation — unlike sum(), count(), avg() or median(), it hands you a shorter array rather than a single value. That single fact explains most of the confusion around it. A formula whose entire body is unique({Products}) will show you a list, which is rarely what you wanted in a sortable column.

The useful form is almost always the nested one:

count(unique({Products}))

Read it inside out: gather the values, strip the repeats, count what's left. That's a distinct count — a number you can sort a view by, filter on, and trigger automations from. Force the output type to Number rather than leaving it on Auto.

Volume is not breadth

Attio's array functions pair naturally with multi-value attributes and relationship attributes, which is the part that makes this interesting. A formula on a Company can reach into its linked records, so unique() can dedupe values gathered from across an account's whole deal history — not just from one field on one record.

That gives you two numbers about the same account, and they answer different questions:

FormulaQuestion it answersWhat high means
count({Deals})How many times did they buy?Repeat purchaser
count(unique({Products}))How many different things did they buy?Broad adoption

An account with six deals for the same product scores 6 and 1. An account with two deals across two product lines scores 2 and 2. By volume the first account is three times the customer. By breadth the second one is twice as entrenched — and entrenchment, not order count, is what predicts renewal.

Most CRMs show you the first column and stop. The gap between the two columns is where the whole account-management conversation lives, and it takes one nested function to surface it.

Cross-sell whitespace on every account

Once distinct products is a live number, whitespace becomes arithmetic. If you sell four product lines:

4 - (count(unique({Products})) ?? 0)

That's how many of your lines the account has never bought — a whitespace score on every record, refreshed automatically as deals close. Sort descending and you have a cross-sell target list that nobody had to build, maintained by nobody.

The categorical version reads better in a view:

if((count(unique({Products})) ?? 0) <= 1, "Single-product", if((count(unique({Products})) ?? 0) >= 3, "Broad adoption", "Expanding"))

Single-product accounts are the cross-sell pipeline. Broad-adoption accounts are the reference and case-study pool. "Expanding" is the middle you actually want to nudge. Three buckets, one formula, and a saved view per bucket that stays correct on its own.

The hard-coded catalogue size is the weak point — if you launch a fifth line, the formula silently understates whitespace for every account until someone edits it. Put the number in the attribute description so the next person knows what to change.

Concentration risk, made filterable

The same shape answers a different question when you point it at where things came from instead of what was sold.

count(unique({Lead source}))

Across an account's linked records, how many different channels ever produced something? One means the entire relationship rests on a single path to that company. If that channel is a conference that gets cancelled or a partner that churns, you don't lose a lead — you lose the account's whole origin story and any way to reproduce it.

if((count(unique({Lead source})) ?? 0) == 1, "Single-channel", "Multi-channel")

The people-side version is the one account teams feel immediately:

count(unique({Deal owner}))

How many different reps have ever worked this account. One is a knowledge-concentration risk: everything known about that customer lives in one person's head, and it walks out with them. This is the relationship-depth cousin of the single-threaded-deal flag from the count() deep dive — that one counts contacts on their side, this one counts owners on yours. Both are risks that every experienced leader knows about and almost no CRM displays.

Deduplicating multi-value noise

The unglamorous use is still worth shipping. Multi-value attributes accumulate repeats — from enrichment providers writing the same tag twice, from imports that ran more than once, from two integrations mapping to the same field. Every count built on top of those fields is quietly inflated.

count(unique({Categories}))

That's the honest tag count. If it differs from count({Categories}) on a meaningful share of your records, you've just found a data-hygiene problem that was silently skewing every segment count in your reporting, and the difference itself makes a decent audit column:

count({Categories}) - count(unique({Categories}))

Anything above zero has duplicates. Sort descending, fix the worst offenders, and check whether the integration that created them is still running.

Composing with the rest of the library

13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">unique() is a middle link — it takes an array and gives back an array, which means it slots between the thing that produces values and the thing that reduces them. count() is the usual outer wrapper, but the others work the same way conceptually, and the whole array family is covered in the [pillar guide's math section.

Downstream, distinct counts feed everything else. Wrap one in if() for a label. Combine it with a second condition using and() when a flag should require both breadth and value:

and((count(unique({Products})) ?? 0) >= 3, {ARR} > 50000)

Broad *and* big — the accounts that belong in a reference programme, as a Checkbox you can filter on rather than a list someone maintains by hand.

And blank-safety matters here for the reason the ?? deep dive laid out: an empty input evaluates to null, and null flowing into a comparison can blank the whole result. The accounts with *nothing* linked are exactly the ones a whitespace formula exists to surface, so guard the count with ?? 0 before you compare it to anything.

CRM use cases that earn their keep

  • Distinct products per accountcount(unique({Products})) — the cleanest cross-sell whitespace metric there is. Important because it separates repeat buyers from broad adopters, and only one of those is actually entrenched.
  • Whitespace score4 - (count(unique({Products})) ?? 0) — how many of your lines they've never bought. Important because it turns cross-sell from a quarterly spreadsheet exercise into a sortable column.
  • Single-product flag — the if() version above. Important because single-product accounts churn at the first budget review and multi-product accounts don't.
  • Channel concentrationcount(unique({Lead source})) — how many paths ever produced business here. Important because a one-channel account is one cancelled conference away from unreproducible.
  • Owner concentrationcount(unique({Deal owner})) — how many reps have touched the account. Important because everything known about a one-owner account leaves with that owner.
  • Duplicate-tag auditcount({Categories}) - count(unique({Categories})) — inflation in your multi-value fields. Important because duplicated tags quietly skew every segment count you report on.
  • Reference-programme flag — the and() version combining breadth with ARR. Important because your best case studies are already in the data, waiting to be filtered for.

Copy-paste formulas

Swap in your attribute names and these work as-is:

Distinct products per account (Number output):

count(unique({Products}))

Cross-sell whitespace against a four-line catalogue (Number output):

4 - (count(unique({Products})) ?? 0)

Adoption-breadth tiers (Text output):

if((count(unique({Products})) ?? 0) <= 1, "Single-product", if((count(unique({Products})) ?? 0) >= 3, "Broad adoption", "Expanding"))

Single-channel origin flag (Text output):

if((count(unique({Lead source})) ?? 0) == 1, "Single-channel", "Multi-channel")

Owner-concentration risk (Checkbox output):

(count(unique({Deal owner})) ?? 0) <= 1

Duplicate-tag audit column (Number output):

count({Categories}) - count(unique({Categories}))

Reference-programme candidate (Checkbox output):

and((count(unique({Products})) ?? 0) >= 3, {ARR} > 50000)

Gotchas

It returns an array, not a number. This is the one that trips people up first. unique({Products}) alone gives you a list; count(unique({Products})) gives you something you can sort and filter. If your formula column shows values instead of a figure, you forgot the wrapper.

Guard the count, not the array. Put the fallback where a number is expected — (count(unique({Products})) ?? 0) — rather than trying to default the array itself. The count is what your comparison touches.

Don't assume normalisation. The documented behaviour is duplicate removal. Whether "Acme", "acme" and "Acme " are treated as one value or three is not something to guess at — and Attio's text library is small (contains, replace, replaceAll, length), with no case-conversion or trim function to clean the values first. Build the formula, look at an account you know has messy data, and confirm what the number says before anyone reports on it.

Distinct isn't the same as meaningful. Two SKUs from the same bundle count as two distinct products, and a rep who touched one deal for a day counts as an owner. If a breadth number is driving territory or expansion decisions, check what's actually in the array on a few records first. Every formula in this series makes something visible; none of them make it true.

Force the output type. Number for the counts, Checkbox for the flags, Text for the tiers. Auto usually guesses right and occasionally doesn't, and a mistyped column breaks the filters built on top of it.

Three formulas deep is the ceiling. A formula attribute can reference other formula attributes up to three levels. count(unique(...)) inside an if() inside an and() is all one formula, so nesting like that is fine — the limit is on chaining separate formula *attributes*, not on how much you write in one editor. Still, if you find yourself building a formula on a formula on a formula to get one distinct count, collapse it back into a single expression while you still remember how it works.

Final thoughts

unique() is the least glamorous function in Attio's math family and the one most likely to change a conversation. Every other number in the CRM tells you how much. This one tells you how many different — and "how many different products, channels, owners, stakeholders" is the question underneath most account reviews that currently gets answered from memory.

Pick the one array that matters most to your motion — products for expansion teams, lead sources for marketing, owners for CS — ship count(unique({That one})), and put a saved view on top of the accounts scoring 1. That view is your cross-sell list, your risk register, or your knowledge-transfer backlog, depending on which array you chose. It costs one formula and it maintains itself.

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

And if you'd rather have whitespace scores, concentration flags, and rollup formulas designed and shipped for you, that's 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.

Book a free discovery call