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

The ! operator in Attio: why flipping a condition does not flip the record set

Written by

Published 25 min readTested in live Attio workspaces
Contents20 sections
  1. What the negation operator does
  2. The only operator whose description names a type
  3. The two operators documented twice are the two that touch emptiness
  4. Negation is not the complement you think you are getting
  5. An Attio checkbox is not a boolean and the documentation says so
  6. Correcting a formula this series published in August
  7. You do not need to know what negating a blank returns
  8. The guard belongs next to the attribute, not around the negation
  9. The one comparison that makes negation safe
  10. A checkbox column renders three states as two
  11. The shortest formula in the series is the one most likely to be wrong
  12. Choosing between the operator and the function
  13. Not equal to is its own operator, not a negated equals
  14. What you cannot negate
  15. Inverting a view is not the same as inverting a definition
  16. The operator table is complete and every article ended in the same place
  17. CRM use cases that earn their keep
  18. Copy-paste formulas
  19. Gotchas
  20. Final thoughts

One character. One line of documentation. ! is "Negates a boolean value", with !true → false as the example, and that is the entire official account of the last symbol in Attio's operator table.

It is also the only operator in that table that takes a single argument, and the only one that looks like it cannot possibly be wrong. Negation is the safest idea in logic: whatever the condition caught, the negation catches the rest. Flip the sign and you have the complement.

In a CRM you do not have the complement. You have the rest of the records that had an answer, and the ones that had no answer fall out of both sides at once.

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(), the comparison operators, and the arithmetic operators. This is the last operator in the table, which makes it a good place to notice that all twelve of them failed in the same way.

color:var(--color-text-heading)]">Prefer to watch? There is a short video for this one: [Attio ! operator: the shortest formula in the series. 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 negation operator does

It takes a boolean and returns the other one.

ExpressionResult
!truefalse
!falsetrue
!{Is customer}true for a prospect, false for a customer

Attio documents it in two separate places, with two different sentences. In the operator table it is "Negates a boolean value." In the logic function table it appears as not(value) with the alternative syntax !value, described as "Returns the opposite of a boolean value. Converts true to false and false to true."

Those are the same operation. The function form and the operator form are interchangeable, which the documentation states outright by listing them on one row. Everything in this article applies equally to both.

What matters is the second description, because it is more specific than it looks. "Converts true to false and false to true" is a complete enumeration of a domain with two members. It is the documentation telling you exactly what negation does to every possible input — on the assumption that there are two possible inputs.

The only operator whose description names a type

Here is something you can verify on Attio's functions page in about ten seconds. Search it for the word "boolean".

It appears three times on the entire page.

Once on ! — "Negates a boolean value." Once on not() — "Returns the opposite of a boolean value." And once on if() — "The condition must evaluate to a boolean value."

That is the whole appearance of the boolean type in a reference covering more than sixty functions. Which means the only places Attio names the type are the two descriptions of negation and the one requirement on 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">if(). The six [comparison operators — the things that actually *produce* booleans in your workspace, and the input to nearly every negation anyone writes — never mention the type at all. == is "Equal to". > is "Greater than". No type, in or out.

So the language documents where booleans are consumed and never documents where they come from. The one sentence stating a type requirement on a logical input — "The condition must evaluate to a boolean value" — is attached to if(), one row away from the operators that can quietly fail to meet it.

That requirement is worth reading twice, because it is unusual. The word "must" appears three times on the whole page. The other two are about text: dateAdd()'s unit type "must be a text value such as "days"", and dateDiff()'s unit type "must be one of" a listed set. if()'s condition is the only place in the library where Attio states a type requirement on a logical expression — and it is stated as a rule with no description of what happens when you break it.

The two operators documented twice are the two that touch emptiness

Twelve operators are in that table: +, -, *, /, ==, !=, >, >=, <, <=, ! and ??.

Exactly two of them also appear in a function table with a named function form: ! appears as not(value), and ?? appears as ifNull(left, right). Nothing else in the operator table has a function twin.

Those two are also, and not coincidentally, the only two operators whose behaviour depends on the shape of what you feed them rather than on arithmetic or ordering. ?? is the only operator in the table whose description mentions null at all. ! is the only one that names the type it requires. They are the two operators about the empty case.

And across all four of those descriptions — two for !, two for ?? — the documentation never says what negating an empty value does. It got two chances per operator and used all four to describe the easy half.

Negation is not the complement you think you are getting

This is the finding, and it is not about ! being broken. It is about what ! is being handed.

The comparison operators article established the mechanism: an empty attribute in Attio evaluates to null, null is contagious, and a comparison against it returns neither true nor false. {Deal value} > 50000 has three outcomes, not two.

Now negate it.

{Deal value}{Deal value} > 50000!({Deal value} > 50000)
80000truefalse
20000falsetrue
emptyemptynot true

Read the two right-hand columns as record sets. The first is "big deals". The second is meant to be "everything else". It is not. It is everything else *that had a deal value*. The record with an empty deal value is in neither column, and it was in neither column before you negated anything — negation inherited the gap and hid it, because the result now looks like a complete inversion of a complete question.

Set complement is the one operation in logic you are allowed to trust. !A is everything that is not A, and A plus !A is everything. That is true of sets. It is not true of a CRM column computed from a field somebody forgot to fill in, where A plus !A adds up to less than your pipeline and the shortfall is invisible in both directions.

Which records go missing is the part that should bother you. They are not a random sample. They are the deals nobody has sized, the accounts nobody has enriched, the records that came in through an import that mapped nine fields out of eleven, the ones created last week by a form. Blankness correlates with new, unowned and unreviewed. The records that fall out of both sides of your negation are the records that most needed someone to look at them.

An Attio checkbox is not a boolean and the documentation says so

The threshold case above is bad. The checkbox case is worse, because a checkbox *looks* like it cannot be empty. It is drawn as a box. A box is either ticked or it is not.

Attio's own documentation says otherwise, in the type conversion section, on checkbox():

"Converts a value to a checkbox. Non-empty or non-zero values become true. Empty values stay empty."

Empty values stay empty. That is the product stating in writing that an empty checkbox is empty — not false. Which means a Checkbox attribute in Attio has three states that matter to a formula: ticked, unticked, and never touched. Two of them render identically.

So !{Is customer} — the single most natural negation anyone writes in a CRM — has three outcomes:

{Is customer}!{Is customer}What it means
tickedfalseA customer, correctly excluded
untickedtrueA prospect, correctly included
never setnot trueA record nobody has classified, silently excluded

The third row is every account that has never been looked at closely enough for someone to decide. On a sequence-targeting field, that is the newest and least-qualified records in the workspace dropping out of your prospect list, which is the opposite of what the field was built for.

Correcting a formula this series published in August

This series published 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">!{Is customer} as a finished answer, in the [and(), or() and not() article, described as "prospects only, customers excluded".

That is wrong on an unset checkbox, for exactly the reason above, and it is the second time in three articles that a rule this series taught has turned out to need a guard. The arithmetic article had to correct ?? 0 on a divisor. This one corrects an unguarded negation.

The pattern is the same both times, and it is worth naming rather than quietly patching. A formula that is right on every record you tested is not the same as a formula that is right. Both of these were tested on records that had data. The failure case is the record that has nothing, and the record that has nothing is the one you are least likely to open while you are building a field.

The corrected form puts the fallback on the attribute:

!({Is customer} ?? false)

Now the negation only ever receives true or false, which is what its documentation says it operates on, and the unset records are included in the prospect list by a decision you made rather than excluded by one you did not.

You do not need to know what negating a blank returns

A fair objection: what does !null actually return in Attio? An empty value, or true?

The documentation does not say. The series has not tested it. And it does not matter to the recommendation, which is the useful part — both possible answers are wrong answers, so the fix is the same either way.

If negating an empty value returns an empty value, every unclassified record silently drops out of your prospect list, as described above.

If negating an empty value returns true — the other possibility, and the one that superficially sounds more convenient — then every record nobody has ever classified is swept *into* the prospect list, including the ones that are actually customers but whose checkbox was never ticked. That is a cold email to a paying customer, which is a worse outcome than a missing record.

So the guard is not a hedge against an unknown. It is the only form of the formula that is correct under both answers. If you want to know which one your workspace does, build !({Something} ?? false) and !{Something} as two columns side by side on an object with some empty records and look at the difference — it takes about a minute, and the answer is worth writing down for your team either way.

The guard belongs next to the attribute, not around the negation

The comparison operators article put this as a law: resolve emptiness where the meaning lives, not where the symptom shows up. A unary operator is where that law gets its clearest test, because there are two places to put the guard and only one of them is inside.

!(({Deal value} ?? 0) > 50000)

The ?? sits on the attribute. The comparison receives a number and returns a real boolean. The negation receives a real boolean and returns the other one. Nothing in the chain ever handles a null, and the decision you made — "a deal with no value is treated as a zero-value deal" — is recorded at the point where it means something.

Compare the shape that patches the end:

(!({Deal value} > 50000)) ?? true

The blank travels through the comparison, through the negation, and gets a value bolted on at the top. It may even produce the answer you wanted. But the question has changed from "what does a missing deal value mean about this deal" to "what should I display when the formula came back with nothing", and the second question has less information in it and is being asked by someone who has lost track of why.

With a unary operator there is one more wrong place to put it, and it is tempting:

!({Deal value} > 50000) ?? false

That reads like a guard on the negation. Whether it binds the way you expect depends on precedence you should not have to think about. Put the fallback where there is no ambiguity at all: directly against the attribute, inside every bracket. The ?? operator article covers choosing the fallback value itself, which is a separate and equally consequential decision.

The one comparison that makes negation safe

There is exactly one input on which ! is total — where it has only two possible results and needs no guard.

!({Owner} == null)

== null is the one comparison that does not propagate emptiness. It cannot, or it would be useless: a check for emptiness that returned empty on empty records would answer only the question nobody asked. So {Owner} == null returns a real boolean on every record in the table, always, with no fallback.

Which makes it the only thing in the language you can negate without thinking. !({Owner} == null) is "has an owner", and it is correct for every record including the broken ones.

That is more useful than it sounds, because it gives the self-maintaining data-hygiene dashboard its positive columns:

!({Owner} == null)
!({Renewal date} == null)
!({Deal value} == null)

Set each output type to Checkbox and you have a coverage view — what proportion of records have the field — rather than a gap view. Both are useful and they are genuinely different reports. Group by the gap version to work a queue; average the coverage version to track whether the queue is shrinking.

And it suggests the general habit: if you want to negate something, negate a question that every record can answer. The reason == null is safe is not that it is special. It is that it is total. Any expression you have made total with a guard is equally safe to negate.

A checkbox column renders three states as two

The reason none of this ever gets caught is that the interface agrees with the mistake.

Set a formula's output type to Checkbox and the result renders as a box. true draws a tick. false draws an empty box. And an empty result also draws an empty box, because there is no third thing for it to draw.

So in a column of two hundred records, the ones where your negation produced nothing look exactly like the ones where it produced false. There is no visual difference between "we evaluated this and the answer is no" and "we never found out". A filter treats them the same way too: both are absent from the ticked set.

This is the mechanism behind every finding in this series' operator articles, and it is worth stating plainly because it is not a bug and nobody is going to fix it. A boolean column has two renderings and three meanings. If you need the third meaning visible, do not use a boolean output type — return text:

if({Is customer} == null, "Unclassified", if({Is customer}, "Customer", "Prospect"))

Three states, three labels, every record in exactly one bucket, and a group-by that adds up to the full table. The unclassified count becomes a number someone can be responsible for instead of a silence.

The shortest formula in the series is the one most likely to be wrong

!{Is customer} is fifteen characters. It is the shortest useful formula in forty-one articles, and it is wrong in a way that !(({Deal value} ?? 0) > 50000) is not — not because it is simpler, but because being that short left nowhere to put a decision.

Every other formula in this series has a seam in it. There is a fallback to choose, a unit to name, a threshold to write down, a format string to get right. Each of those seams is a place where you had to think about an edge case because the syntax forced you to. The one-character negation of a bare attribute has no seams. You type it, it reads like English, and no part of the syntax asks you a question.

That is the general version of this article's warning, and it applies well beyond !. The formulas that need review are not the long ones. The long ones were reviewed while they were being written. The dangerous field is the obvious one-liner that nobody has reread since the day it was created, because it never looked like it could be wrong.

Choosing between the operator and the function

!value and not(value) are the same operation. Attio says so in the documentation by putting them on one row. So the choice is purely about the next person to read the field, which in practice is you in six months.

not() reads better when the negation is the point of the formula:

not(hasBeenIn({Deal stage}, "Closed lost"))

Nobody misses that. The word is there, it cannot be confused with anything else, and the reader knows what the field is for before they finish the line.

! reads better inside a longer expression, where a function wrapper adds brackets that make the structure harder to see:

and({Employee range} >= 200, !({Is customer} ?? false))

The case to avoid is the bare one-character form standing alone as an entire formula. !{Is customer} and {Is customer} differ by a single character at the very start of the line, in a UI where the formula is often shown truncated or in a small font. Two fields with opposite meanings that look nearly identical is an accident waiting for a hurried afternoon. If the whole formula is a negation, write not().

Not equal to is its own operator, not a negated equals

Worth separating, because the symbols invite the confusion. != is a single operator in the table — "Not equal to" — not ! applied to ==. You cannot write !==, and !(a == b) and a != b are different expressions even though they usually agree.

They stop agreeing in the place everything in this article stops agreeing: when a is empty. a != b has the three outcomes the comparison article describes. !(a == b) is a negation of a comparison and inherits whatever negation does to an empty value. If you have guarded the attribute, both are fine and != is shorter. If you have not, you have two subtly different bugs instead of one.

Use != for inequality. Use ! for negating something that is already a boolean. Mixing the two — !({Stage} != "Closed lost") — is a double negative that means == and reads like a riddle.

What you cannot negate

Negation requires a boolean, and most things in a CRM are not booleans. These do not work, and the reason is if()'s documented requirement that a condition must evaluate to a boolean value:

  • !{Deal value} — a number is not a boolean. Write the comparison you mean: !(({Deal value} ?? 0) > 0).
  • !{Stage} — a status is not a boolean. Negate a comparison on it: !({Stage} == "Closed lost").
  • !{Renewal date} — a date is not a boolean. Use !({Renewal date} == null) for coverage, or a guarded comparison for a countdown.
  • 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">!{Email addresses} — multi-value attributes are lists and reject most things. Index the primary value first, as the [split() article covers, then compare, then negate.

In other languages a number or a string gets coerced to a boolean and the formula silently means something. Treat Attio as if it does not, write the comparison explicitly, and you get a field whose meaning is legible without knowing any coercion rules.

Inverting a view is not the same as inverting a definition

One more place negation earns its keep, briefly, because the and(), or() and not() article makes the full argument.

A saved filter is a view. A boolean formula attribute is data. That difference is why "not a customer" belongs in a formula rather than in ten people's saved views: there is one definition, it is visible on the record, you can report on it, an automation can fire from it, and changing the rule changes every record at once.

The negation-specific version: if you already have a formula attribute for a positive definition, do not build a second filter for the negative one. Build the negated formula, guard it, and let the two columns add up to the table. Two columns that provably sum to your record count is a data-quality test you get for free, and it is the fastest way to notice the blank problem this whole article is about — if ticked plus unticked is less than total, you have found your unclassified records.

The operator table is complete and every article ended in the same place

With ! done, every one of the twelve operators in Attio's table has been covered: ?? in August, the six comparisons and the four arithmetic ones last week, and this one.

They have almost nothing in common. One sets a fallback, six compare, four do maths, one negates. And every single article ended up in the same place, which was not planned: the empty value.

?? exists only because of it. The comparison operators have a third outcome because of it. Arithmetic propagates it, and the guard against it turned out to be a bug on a divisor. Negation is documented over two states and handed three because of it. Twelve symbols, four articles, one subject.

The documentation does not work this way. It describes twelve operators in twelve short rows, mentions null exactly once and the boolean type three times, and says nothing about emptiness anywhere else. That gap is where every useful finding in this series came from, and it is not going to close on its own. The practical version: whatever you are building, build the empty record on purpose and look at what the column does.

CRM use cases that earn their keep

  • Prospect targeting that includes the unclassified. !({Is customer} ?? false) — cold sequences that reach the records nobody has got to yet, instead of silently skipping them.
  • Field coverage columns. !({Owner} == null) — the positive half of a data-hygiene dashboard, safe to negate because == null is total.
  • Re-engagement exclusion. not(hasBeenIn({Deal stage}, "Closed lost")) — previously lost accounts stay out of automated outreach, where a cheerful email to someone who already said no does real damage.
  • Below-threshold buckets that do not lose records. !(({Deal value} ?? 0) > 50000) — a small-deals view that adds up with the big-deals view to your whole pipeline.
  • Three-state classification instead of a lossy boolean. An if() returning "Customer", "Prospect" and "Unclassified" turns the silent gap into a queue someone owns.
  • Open-pipeline definition. !({Stage} == "Closed won") and !({Stage} == "Closed lost") with a guarded stage, or better, the three-state if() — because "open" is the definition most likely to be quoted in a board meeting and most likely to be missing records.

Copy-paste formulas

Guarded checkbox negation, the corrected form of the one this series published in August:

!({Is customer} ?? false)

Guarded threshold negation — the fallback sits on the attribute, inside every bracket:

!(({Deal value} ?? 0) > 50000)

Coverage columns, safe without a guard because == null is total:

!({Owner} == null)
!({Renewal date} == null)
!({Deal value} == null)

Three-state classification, for when the unclassified records need to be visible rather than guessed:

if({Is customer} == null, "Unclassified", if({Is customer}, "Customer", "Prospect"))

Exclusion from automated outreach, using the function form because the negation is the whole point:

not(hasBeenIn({Deal stage}, "Closed lost"))

Negation inside a longer condition, where the operator form reads better than a wrapper:

and({Employee range} >= 200, !({Is customer} ?? false))

Open pipeline as a guarded boolean:

!(({Stage} ?? "") == "Closed won") and !(({Stage} ?? "") == "Closed lost")

The side-by-side test, if you want to know what your workspace does with an empty value — build both on an object that has some blank records and compare the columns:

!{Is customer}
!({Is customer} ?? false)

Gotchas

  • A negation is not a complement. !A is everything that is not A *and had an answer*. Blanks are in neither set, and the two sets add up to less than your record count.
  • An empty checkbox is empty, not false. Attio's checkbox() documentation says empty values stay empty. Guard with ?? false before negating.
  • Both possible behaviours of a negated blank are wrong. Either unclassified records drop out of your list or they all get swept in. Guard regardless of which one your workspace does.
  • Put the fallback on the attribute, inside every bracket. Not around the negation, and not on the result.
  • != is its own operator, not ! plus ==. Use it for inequality; use ! only on something already boolean.
  • A Checkbox output type renders three meanings as two pictures. If the unclassified records matter, return text with three labels instead.
  • Never negate a non-boolean. !{Deal value} and !{Stage} are not shorthands for anything. Write the comparison.
  • ! and not() are the same operation. Choose on readability, and prefer not() when the whole formula is a negation — one character of difference at the start of a line is too easy to miss.
  • color:var(--color-text-heading)]">Mind mixed precedence. The [and(), or() and not() article covers it: parenthesize every expression that mixes and with or, negated or not.
  • The shortest formulas are the least reviewed. A one-liner that reads like English had nowhere to put a decision, which is not the same as not needing one.

Final thoughts

Negation is the operator people trust most and check least, because set complement is the one piece of logic that feels like it cannot have an edge case. In a CRM it has exactly one, and it is the edge case every operator in this language has: the record where the field is empty.

Attio documents ! twice, in two tables, with two sentences, and one of them enumerates the complete domain it works on — "Converts true to false and false to true". That is a two-state description of an operator you will almost always point at a three-state expression. Nothing warns you. The column renders, the filter runs, the view looks complete, and the records that fell out of both sides are the newest and least-reviewed ones in the workspace.

The rule is short enough to remember: a negation is a claim that the thing you are negating has exactly two states. Check the claim before you ship the field. Usually that means one ?? against the attribute, inside the brackets, so the negation only ever sees a real boolean. Occasionally it means admitting that three states are real and returning three labels instead of a checkbox.

And that is the twelfth and last operator in the table. Four articles, twelve symbols, and every one of them turned out to be a story about the empty value — which is the thing the documentation mentions once.

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.

If you would rather have your qualification fields, exclusion logic and data-hygiene columns built so they count every record the first time, that is 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.