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

Comparison operators in Attio: the six symbols every flag in your CRM is built on

Written by

Published 22 min readTested in live Attio workspaces
Contents18 sections
  1. What the six comparison operators do
  2. Every example in the docs is a comparison nobody would write
  3. The one expression whose type you do not choose
  4. Both sides can be an attribute
  5. A threshold in a formula body is a policy in one place
  6. Comparing to a stage name is a string match on a label
  7. Case sensitivity is documented for the functions, not for the operator
  8. Normalise both sides and the question stops mattering
  9. What a blank does to a comparison
  10. Guard the left side, never the result
  11. The bucket that loses records
  12. The one comparison a blank survives
  13. Date comparisons change while nobody edits anything
  14. How far versus which side
  15. CRM use cases that earn their keep
  16. Copy-paste formulas
  17. Gotchas
  18. Final thoughts

Six symbols, one line each in Attio's documentation. ==, !=, >, >=, <, <=, described as "Equal to", "Not equal to", "Greater than", and so on, with an example apiece.

They are also the foundation of nearly every useful thing in a CRM. Every overdue flag, every big-deal threshold, every stale-record warning, every qualification checkbox and every "is this account on the plan we think it is" column comes down to a comparison. if() cannot run without one. and() and or() have nothing to combine without one.

So it is worth noticing that the operators doing the most work in your workspace are the least documented part of the language — and that the gaps are exactly where the mistakes live.

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(), and currency()/checkbox()/rating(). This one covers all six comparison operators together, because they fail in the same places.

color:var(--color-text-heading)]">Prefer to watch? There is a short video for this one: [Attio comparison operators: turn any threshold into a checkbox. 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 six comparison operators do

Each takes two values and returns true or false:

OperatorMeansDocumented example
==Equal to100 == 100 = true
!=Not equal to100 != 50 = true
>Greater than100 > 50 = true
>=Greater than or equal to100 >= 100 = true
<Less than50 < 100 = true
<=Less than or equal to50 <= 100 = true

They are operators, not functions, so they sit between their two values rather than wrapping them. No commas, no brackets, nothing to close:

{Deal value} > 50000

That is a complete formula. Set the attribute's output type to Checkbox and you have a filterable, reportable, automation-triggering field defined by one line that anyone can read.

Every example in the docs is a comparison nobody would write

Look again at that table, and specifically at the right-hand column. 100 == 100. 100 > 50. 50 <= 100.

Every single documented example compares a number literal to a number literal. Not one of them references an attribute. Not one of them compares text, a date, a currency value, a select option or a checkbox. The entire documented surface of the comparison operators is arithmetic on constants, which is the one thing nobody has ever needed a CRM formula for — the answer to 100 > 50 was never in question.

It is worth comparing against how the same table treats ??, which gets a full sentence of explanation ("Returns the left value if it is not null, otherwise returns the right value. Useful for setting a fallback when an attribute might be empty") and the only example in the operator table that touches real data: {ARR} ?? 0. The ! operator at least tells you what type it works on. The six comparisons get three words and a sum.

This is the same pattern the currency(), checkbox() and rating() article found one layer down: documentation depth running inverse to risk. The functions and operators that are hardest to get wrong are described most carefully. The ones that quietly decide what shows up in your pipeline reports get a one-word gloss.

Which means the behaviour you need — text comparison, date comparison, stage comparison, blank handling — is not documented anywhere. It is inferred, or it is tested. The rest of this article is the inferring and testing.

The one expression whose type you do not choose

A running rule in this series: force the output type, never leave a formula attribute on Auto. Decide whether the column is a number, a date, text or a checkbox, and say so.

A comparison is the one expression where half of that decision is already made for you. > returns a boolean. It cannot return anything else. The operator fixes the type of the result, and no amount of configuration will turn {Deal value} > 50000 into a number or a date.

What you still choose is whether Attio *stores* it as a checkbox. That distinction matters more than it sounds:

  • The operator decides the shape of the answer: two states, true or false.
  • The output type decides whether the workspace can use it: a Checkbox column is filterable, groupable, reportable and triggerable. On Auto you get something that looks right in the cell and behaves unpredictably everywhere else.

So the rule survives, in a sharper form. With most functions you are choosing a type. With a comparison you are confirming one. Set it to Checkbox anyway — not to change the result, but to declare it.

Both sides can be an attribute

Because the docs only ever show literal-to-literal comparisons, the most valuable form is the one you have to discover: both sides can be attributes.

{Closed On} > {Expected close date}

That is "this deal closed later than we said it would", with no threshold to maintain, no number anyone has to agree on and nothing to update next quarter. The rule is the relationship between two fields the workspace is already keeping.

A few more that cost one line each:

{Deal value} > {Previous deal value}
{Actual spend} > {Approved budget}
{Renewal date} < {Contract end date}

Each of those is a question that would otherwise live in a spreadsheet, a quarterly audit or someone's memory. A comparison between two attributes is the cheapest form of data quality check in the product: it does not need a target, it needs two columns that are supposed to agree.

The advantage over a hardcoded threshold is that an attribute-to-attribute comparison stays correct as the business changes. Deals get bigger, budgets get revised, dates slip. The comparison does not care, because it never knew a number in the first place.

A threshold in a formula body is a policy in one place

When you do need a threshold, a comparison is still an upgrade on the alternative, and it is worth being explicit about why.

{Deal value} > 50000

The number 50000 is now written down exactly once, in a place with a name, visible to anyone who opens the attribute. Before the formula existed, "big deal" lived in a saved filter per person, a slide from last quarter, and three people's heads, none of which agreed.

But a literal in a formula body is the weakest of the good options. The honest hierarchy:

  • Worst: the threshold is in people's heads, so the number in every report depends on who built the view.
  • Better: the threshold is a literal in one formula attribute, so there is one definition and changing it changes every record at once.
  • Best: the threshold is itself an attribute, and the formula compares to it, so the number can be changed by whoever owns the policy without anyone editing a formula.

Worth saying plainly: most teams should stop at "better", because the third option only pays off when the threshold genuinely changes and more than one formula depends on it. One number in one formula body is not technical debt. The same number copied into nine formula bodies is.

Comparing to a stage name is a string match on a label

This is the comparison most workspaces contain the most of, and the one with the sharpest edge:

{Stage} == "Closed won"

It reads like it is comparing to the stage. It is not. Attio's documentation confirms that a select or status attribute surfaces as "the option or stage's title" — so what you have written is a text match against a label, and labels are editable by anyone with access to the pipeline settings.

Rename "Closed won" to "Won" and this formula does not error. It does not warn. It returns false on every record, forever, and the column that drove your win-rate report quietly becomes a column of empty tickboxes. Nothing in the workspace flags it, because nothing is wrong: you asked whether the stage title equals a particular string, and now it does not.

The same exposure runs through the history functions, which take the stage name as a text argument too — hasBeenIn({Stage}, "Negotiation"), valueSetAt({Stage}, "Proposal"), timeSpentIn({Stage}, "Negotiation", "days"). A pipeline rename breaks all of them in the same silent way.

Two practical responses, neither of which is "never do this":

  • Treat pipeline stage names as a schema change, not a cosmetic one. Before renaming a stage, search your formula attributes for the old title. This is a two-minute check that prevents a quarter of wrong reporting.
  • Where the question does not actually need the name, avoid it. "Has this deal ever moved backwards" or "how long has it sat where it is" can often be built from timeSpentIn() and previousValue() without hardcoding a single label.

The general shape, worth keeping: a comparison against a literal is a dependency on something outside the formula. With a number, that something is a business decision. With a stage name, it is a dropdown anyone can edit.

Case sensitivity is documented for the functions, not for the operator

Attio states case sensitivity clearly where it uses functions to compare text. startsWith() and endsWith() are both documented as "The comparison is case-sensitive", and startsWith() goes as far as showing the negative case: startsWith("Hello World", "hello") = false.

For == there is no such statement. There is "Equal to", and 100 == 100.

So the behaviour everyone relies on — whether {Source currency} == "usd" matches a value stored as "USD" — is the one thing the operator table does not cover. The reasonable inference, given the rest of the language, is that == is case-sensitive too. That is an inference, not a fact, and the next section is how to stop caring which it is.

Normalise both sides and the question stops mattering

Do not bet a column on an undocumented behaviour. Make the comparison immune to it:

lower({Source currency} ?? "") == "usd"

Three things happen in that one line. lower() flattens the case on the left. The literal on the right is written in the same case, so the two sides are guaranteed comparable. And ?? turns a blank into empty text before lower() ever sees it, so a record with no currency returns a clean false rather than an empty cell.

That form behaves identically whether == is case-sensitive or not, which is the property you want from infrastructure. It costs eleven characters.

This matters most on text that humans or integrations wrote: imported country codes, CRM-to-CRM migrations, enrichment fields, anything where 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">"USA", "usa" and "Usa" all exist in the same column. Case normalisation is the single highest-yield habit in text comparison, and the [lower() and upper() article has the longer version of the argument.

What a blank does to a comparison

The series has already established the mechanism: in Attio, an empty attribute evaluates to 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">null, and null is contagious. The [?? operator article states the consequence directly — compare against it and the comparison is neither true nor false.

That third outcome is the one worth sitting with. You wrote an expression with two possible answers and got an expression with three:

Left side{Deal value} > 50000Where the record lands
80000trueIn the flagged bucket
20000falseIn the unflagged bucket
emptyemptyIn neither bucket

A record in neither bucket is worse than a record in the wrong one. A wrong flag is visible — someone spots a $20K deal on the big-deals view and asks. A missing flag is invisible by construction: the record does not appear on the "flagged" view, and it does not appear on the "not flagged" view either. The two views look complete, they add up to less than the pipeline, and nobody counts.

And the records this happens to are not random. They are the incomplete ones: the deal nobody has sized, the account with no ARR, the renewal with no date. Which is to say the comparison fails on precisely the records that most needed to be looked at.

Guard the left side, never the result

The fix is one character of ??, and where you put it is the whole decision.

({Deal value} ?? 0) > 50000

Guarding the left side means the blank is resolved before the comparison runs, while the question "what does a missing deal value mean here?" is still yours to answer. You decide the record is treated as a zero-value deal, and it lands in the unflagged bucket on purpose.

Guarding the result instead looks similar and is not:

({Deal value} > 50000) ?? false

This lets the blank through the operator and then patches whatever fell out. It may even produce the right answer. But the decision has moved from "what does missing mean for this field" to "what should I show when the formula produced nothing", which is a worse question with less information in it, asked after the fact.

The rule, which generalises past comparisons: resolve emptiness where the meaning lives, not where the symptom shows up. A missing deal value means something specific about that deal. The emptiness of a boolean two steps later means nothing at all.

One nuance on the fallback itself. 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">?? 0 is right for a > comparison and wrong for a < one — guard a renewal-date countdown with a zero and every blank record becomes urgently overdue. Pick the fallback that puts blanks in the bucket you want them in, which for date comparisons usually means a far-future date or a large day count. The [?? article covers choosing fallbacks properly; the point here is that a comparison is where the choice gets consequences.

The bucket that loses records

!= deserves its own warning, because it reads as an exclusion and behaves as an assertion.

{Stage} != "Closed lost"

In English that is "not lost", and most people building it mean "still alive". But on a record with no stage set, you are comparing a blank to text, and by the rule above the answer is empty — so a stage-less record is not in the "still alive" bucket. It is also not in the "lost" bucket. It is nowhere, and this is the exact field people use to define active pipeline.

If three states are real in your data, write three states:

if({Stage} == null, "No stage", if({Stage} == "Closed lost", "Lost", "Active"))

Now every record has an answer, the unset ones are visible as a category, and the view that someone groups by this field adds up to the whole pipeline. Making the blank into a named bucket is not extra work, it is the work: it turns a silent gap into a data-quality queue.

The habit worth forming is to stop asking "is this true or false" of a CRM comparison and start asking "how many buckets does this field actually have, and does every record land in one?"

The one comparison a blank survives

There is a single exception to everything above, and it is the one comparison form you will reach for on purpose:

{Estimated ARR} == null

Every other comparison with an empty operand propagates the emptiness. This one has to not, or it would be useless — an emptiness check that returned empty on empty records would answer only the question it was never asked. So == carries two distinct behaviours depending on its right-hand side: compared to a value, a blank left side gives you nothing; compared to null, a blank left side gives you true.

That makes == null the entry point for every data-hygiene field in the workspace:

{Owner} == null
{Renewal date} == null
{Deal value} == null

Set each to a Checkbox output type and you have a missing-data dashboard that maintains itself. The distinction against 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">??, covered at more length in the [?? article: use ?? when a substitute keeps the math honest, and == null when the emptiness *is* the signal you want to see.

Date comparisons change while nobody edits anything

Numeric and text comparisons only change when the data does. Date comparisons are different, and it is the most under-appreciated property of the whole operator set.

{Renewal date} < today()

That is the overdue flag every workspace has. Nobody edits the record and nobody edits the formula, and yet the value flips — because the right-hand side moves on its own. Every formula attribute in the workspace with today() or now() on one side of a comparison is a field that changes while everyone is asleep.

Which raises the question of *when* it flips, and this is where it meets a problem the series has already documented: the day() and hour() article established that Attio reads timestamps in UTC. So "today" rolls over at UTC midnight, not at midnight where your team sits. In Tashkent, five hours ahead, a renewal becomes "overdue" at 5am local. In California it becomes overdue at 5pm the day before.

For a renewals queue that is usually survivable. For anything where the boundary itself is the point — a same-day SLA flag, an end-of-month cutoff, a "contacted in the last 24 hours" field — the offset is the error, and it is an error that shows up as a flag that is right most of the day and wrong near the edges. setTimezone() is the lever when the boundary has to be local, and the honest alternative is to compare on a wider unit so a few hours cannot change the answer.

The other half of this: a date comparison tells you which side of the line a record is on, but nothing about how far. For "overdue", that is enough. For "how overdue", you need the next section.

How far versus which side

The pairing that makes the date library work is worth stating as a rule, because each half is useless alone.

dateDiff() returns the absolute difference. That is a documented behaviour and a deliberate one, and it means dateDiff() can tell you a renewal is 30 days from today and cannot tell you whether those 30 days are in front of you or behind you. Thirty days to renewal and thirty days past renewal come back as the same number.

A comparison carries exactly the information dateDiff() throws away, and nothing else. {Renewal date} < today() knows the direction and not the distance.

So:

if({Renewal date} < today(), "Overdue", "Upcoming")

tells you the side, and:

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

tells you the distance. The field you actually want is both at once, and a single cell reading "Overdue by 12 days" would need string concatenation, which the library does not offer — there is no concat() and no documented joining operator. So in practice you build two columns, or you build the signed number with an if():

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

Negative means behind you, positive means ahead. One sortable column where the top is your most overdue account and the bottom is your furthest-out renewal — and the only reason it works is that a comparison supplied the sign that dateDiff() refused to.

Mental model: dateDiff tells you how far, a comparison tells you which side. Almost every date field worth building needs both.

CRM use cases that earn their keep

  • Big-deal flag, blank-safe — ({Deal value} ?? 0) > 50000 — important because "big" should be one number in one place rather than a different number in every saved view.
  • Closed later than forecast — {Closed On} > {Expected close date} — important because forecast accuracy is a comparison between two fields you already keep, not a report anyone has to build.
  • Overdue renewal — {Renewal date} < today() — important because this is the one flag that updates itself, and the only maintenance it needs is knowing it turns over at UTC midnight.
  • Over budget — {Actual spend} > {Approved budget} — important because an attribute-to-attribute comparison stays correct when both numbers change.
  • Missing owner queue — {Owner} == null — important because == null is the one comparison a blank survives, which makes it the foundation of every data-hygiene view.
  • Currency match, case-proof — lower({Source currency} ?? "") == "usd" — important because it behaves the same whether == is case-sensitive or not, and handles blanks in the same expression.
  • Three-state pipeline status — if({Stage} == null, "No stage", if({Stage} == "Closed lost", "Lost", "Active")) — important because the two-bucket version silently loses every record with no stage.
  • Signed days to renewal — the signed dateDiff() form above — important because one sortable column beats an overdue flag and a countdown sitting in two places.

Copy-paste formulas

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

Big-deal flag, blank-safe (Checkbox output):

({Deal value} ?? 0) > 50000

Closed later than forecast (Checkbox output):

{Closed On} > {Expected close date}

Overdue renewal (Checkbox output):

{Renewal date} < today()

Case-proof, blank-safe text match (Checkbox output):

lower({Source currency} ?? "") == "usd"

Missing-data flag (Checkbox output):

{Owner} == null

Three-state pipeline status (Text output):

if({Stage} == null, "No stage", if({Stage} == "Closed lost", "Lost", "Active"))

Signed days to renewal, negative when overdue (Number output):

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

Gotchas

  • color:var(--color-text-heading)]">A blank is a third outcome. An empty operand makes the comparison empty, not false, and the record falls out of both sides of your filters. Guard the left side with [??.
  • Guard the input, not the output. ({Deal value} ?? 0) > 50000 decides what missing means. ({Deal value} > 50000) ?? false patches a symptom.
  • Pick the fallback for the direction. ?? 0 is safe under > and dangerous under < — a zero guard turns every blank date into an overdue record.
  • Stage comparisons are string matches. Renaming a pipeline stage silently turns {Stage} == "Closed won" false on every record, and breaks the stage names passed to the history functions too. Search your formulas before renaming.
  • color:var(--color-text-heading)]">Case sensitivity on == is undocumented. Attio documents it for startsWith() and endsWith() and not for the operator. Normalise with [lower() instead of assuming.
  • Set the output type to Checkbox. The operator fixes the shape of the answer; the output type is what makes it filterable, reportable and usable in automations.
  • today() on one side means the field changes by itself, at UTC midnight rather than local midnight. Fine for a renewals queue, not fine for a same-day SLA.
  • dateDiff() is absolute. It cannot tell past from future. If you need direction, a comparison is the only thing that supplies it.
  • color:var(--color-text-heading)]">Two values per comparison. Chaining is not a thing — combine comparisons with [and() and or(), and parenthesize when you mix them.

Final thoughts

The comparison operators are the smallest, least documented and most load-bearing part of the formula language. Six symbols, six examples of arithmetic on constants, and every flag in your CRM resting on behaviour the documentation never describes.

Two habits cover most of it. Guard the left side, so a blank lands in a bucket you chose instead of disappearing from both. And prefer an attribute to a literal on the right, so the rule stays true when the business moves.

The last thing is the one that keeps paying off. A comparison is the only expression in the language where the formula body states a color:var(--color-text-heading)]">rule and the attribute states a type — and the rule stays readable to whoever opens it next. {Deal value} > 50000 tells a reviewer what the policy is. A ticked checkbox someone set by hand tells them nothing, and [checkbox({Deal value} > 50000) tells them the same thing as the comparison with an extra function in the way. Write the comparison, declare the type, and the field explains itself.

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 arithmetic operators, where the question stops being what a symbol means and starts being what type comes back out.

And if you would rather have your thresholds, flags and renewal fields designed and shipped for you, 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.