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

The phoneNumber() function in Attio: the only conversion that rejects correct data

Written by

Published 24 min readTested in live Attio workspaces
Contents19 sections
  1. What the phoneNumber() function does
  2. The one table in the docs with no examples
  3. Every other conversion rejects nonsense, this one rejects real numbers
  4. The docs name the precondition and not the failure
  5. Why most CRM phone data will fail this function
  6. There is no concat, and that is supposed to be the end of it
  7. The concat Attio does not document
  8. Choosing a placeholder that cannot appear in your data
  9. replace() takes the first occurrence, and that is the lever
  10. What replaceAll() does to a phone number
  11. Stripping formatting without regex
  12. The single-country repair
  13. The country-aware repair
  14. Phone attributes are lists
  15. A formula cannot repair the attribute it reads
  16. CRM use cases
  17. Copy-paste formulas
  18. Gotchas
  19. Final thoughts

Every failure in Attio's formula language, up to this point in the series, has been a failure of form. number("abc") returns an error because "abc" is not a number, and there is no reading of that input under which it could be. The data was wrong, the function said so, and the fix was to fix the data.

phoneNumber() breaks that pattern, and it is the only function in the library that does.

Hand it "020 7946 0958" and it has nothing to work with. That string is a real London landline. It is correctly formatted, correctly punctuated, and a person in the UK would dial it without a second thought. It is not malformed in any way. It simply does not say what country it belongs to, and E.164 — the format Attio's docs say is required — has no way to represent a number that does not.

The missing piece was never in the string. It was in the head of whoever typed it.

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

color:var(--color-text-heading)]">Prefer to watch? Every function in this series 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 the phoneNumber() function does

One argument, one job:

phoneNumber(value)

Attio's documentation describes it in a single sentence: it converts a text value to a phone number, and "the text needs to include a country code since E.164 format is required."

Read that sentence twice, because it is doing more work than it looks like. It is not describing formatting behaviour. It is not saying the function will tidy up brackets, or guess a country from the length of the number, or fall back to your workspace locale. It is stating a precondition on the input. Meet it and you get a phone number attribute value. Miss it and you do not.

E.164 is the international standard that makes a phone number globally unambiguous: a leading +, then the country calling code, then the national number, with no spaces, brackets, dashes or dots. +442079460958. That is the entire format. There is exactly one way to write any given number, which is precisely what makes it useful to a machine and precisely what makes it rare in a CRM.

The one table in the docs with no examples

Attio's formula functions library opens with a promise. In its own words, the article "lists every available function with its syntax, description, and an example."

It does, five times out of six.

The page holds six function tables — operators, logic, math, date and time, text, type conversion, and attribute history. Five of them carry three columns: Function, Description, Example. Every row shows you a worked case with a result: startsWith("Hello World", "Hello") = true, splitPart("hello@attio.com", "@", 1) = "attio.com", length("Hello World") = 11.

The type conversion table has two columns. Function and Description. No Example column exists, so not one of text(), number(), currency(), checkbox(), rating() or phoneNumber() is shown with a single worked input.

That is a pattern worth naming, because this series has now hit it three times. The random() article found the one row in the math table with no result printed. The number() and text() article found the docs recommending 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">format_date(), a function that does not exist under that name — and that recommendation still sits in the type conversion section today, pointing at the real [formatDate() by the wrong name.

For most functions a missing example is a minor irritation. For phoneNumber() it is the whole problem. The only question anyone has about this function is *what counts as acceptable input*, and that is exactly the question an example would answer and a description cannot.

Every other conversion rejects nonsense, this one rejects real numbers

Line up the six type conversions by how they fail, and phoneNumber() sits on its own:

FunctionFails whenThe input was
text()Never — every attribute type is supportedAnything
number()The value cannot be parsed as a numberGenuinely not a number
currency()Given something that is not a numberGenuinely not a number
checkbox()Never — non-empty becomes trueAnything
rating()Never — out-of-range values are clampedA number, possibly out of range
phoneNumber()The text has no country codeA correct phone number

Four of the six never fail at all. number() and currency() fail on input that was wrong to begin with — nobody argues that number("n/a") should have worked. phoneNumber() is the only one where the rejected input can be completely, verifiably correct.

This is not a bug and it is not Attio being strict for the sake of it. There is no correct answer for "020 7946 0958" to convert *to*. +44 20 7946 0958 is a London number. +1 020 794 60958 is not a valid US number but the digits do not object. A function that guessed would be worse than a function that refuses, because a wrong phone number is indistinguishable from a right one until someone dials it in front of a customer.

The useful reframe: phoneNumber() is not validating your data, it is validating whether your data is self-describing. Those come apart, and only for this function.

The docs name the precondition and not the failure

Here is the second gap, and it matters if you are putting this in a live view.

For number(), Attio is explicit about the consequence: "If the value can't be parsed as a number, the formula returns an error." You know what a bad row will look like.

For phoneNumber(), the docs state the requirement — country code, E.164 — and stop. They do not say whether a number without a country code produces an error like number() does, returns empty like an out-of-range splitPart() does, or does something else entirely. The precondition is documented. The failure mode is not.

So before you build anything on this function, spend two minutes establishing that behaviour yourself: put phoneNumber() on a scratch attribute, feed it one row with a + prefix and one without, and look at what the second row shows. Whether the answer is an error or a blank changes how you guard it, and it is not a question you can settle by reading.

The wider rule this series keeps arriving at: when a document tells you what is required but not what happens when you miss it, the missing half is the half that reaches your users.

Why most CRM phone data will fail this function

Take an honest inventory of where phone numbers in a CRM come from:

  • Typed by a rep during a call. They write it the way they would dial it, which is local format. No country code, because they are in the same country.
  • Pasted from an email signature. Whatever the sender's template does — often +44 (0)20 7946 0958, which carries a country code *and* a redundant zero in brackets.
  • Captured by a web form. Whatever the visitor typed, unless the form had a country selector that actually enforced one.
  • Imported from the previous CRM. Whatever the previous CRM stored, which is whatever the CRM before that stored.
  • Enriched from a data provider. Usually E.164, and usually the only clean source in the workspace.

Only the last of those reliably meets the precondition. In any workspace with a few years of history the realistic split is a minority of E.164 numbers, a large middle of local-format numbers, and a tail of things that are not phone numbers at all — extensions appended with a comma, two numbers in one field separated by a slash, "ask reception".

That distribution is the reason this article is mostly about repair rather than about the function. Calling phoneNumber() on data that already meets the precondition is trivial. Everything interesting happens on the rows that do not.

There is no concat, and that is supposed to be the end of it

So you need to put +44 in front of a string. In almost any other formula language that is one character of syntax.

In Attio it is not there. This series has said so repeatedly and the docs back it up: there is no concat() function in the library, and the operator table documents + as Addition, with the example 100 + 50 = 150. Nothing in the reference describes joining two pieces of text. There is also no regex anywhere in the language, so the usual escape hatch of a substitution pattern is gone too.

Read that list and the conclusion looks forced. You cannot prepend a country code, therefore you cannot repair local-format numbers, therefore phoneNumber() is usable only on data that never needed it.

That conclusion is wrong, and the way out is sitting in the text functions table.

The concat Attio does not document

replace(subject, search, replacement) swaps a literal piece of text inside a string for another piece of text. Attio's own example: replace("Hello World", "World", "There") = "Hello There".

Now notice what happens if the *subject* is a literal you wrote yourself, and the *replacement* is your attribute:

replace("+44~", "~", {Phone raw})

The subject is the constant +44~. The search term is ~. The replacement is the record's value. The result is +44 followed by whatever that record holds — which is string concatenation, built out of a function that was never described as doing it.

Two attributes join the same way, with one nested call per join:

replace(replace("~1~2", "~1", {Country dial code}), "~2", {Local number})

The inner replace() turns the template ~1~2 into +44~2. The outer one turns that into +447700900123. You are not limited to two — each additional piece is another placeholder and another wrapping call.

color:var(--color-text-heading)]">Be clear about the status of this. The behaviour of replace() is documented precisely, with worked examples, and this technique follows from that documented behaviour by construction — it is not an undocumented feature, it is an ordinary use of a documented one that the docs never think to show. It is still derived rather than quoted, which means the honest instruction is: build it on a scratch attribute, confirm the output on three real records, and only then put it anywhere that matters. That is the right standard for anything in this series that Attio has not written down itself, and it is the same standard the [random() article applied to its recalculation probe.

If it holds up in your workspace, it is worth more than this article. A concatenation primitive unlocks a great deal beyond phone numbers: building a display label out of two fields, prefixing an ID with a project code, assembling a URL from a slug.

Choosing a placeholder that cannot appear in your data

The technique has exactly one sharp edge, and it comes from replace() matching literal text.

In replace(replace("~1~2", "~1", A), "~2", B), the inner call runs first and produces A~2. The outer call then searches that whole string for ~2 — including the part that came from A. If A happens to contain the characters ~2, the outer replace hits the copy inside your data instead of the one in your template, and the result is quietly wrong.

So pick placeholders that cannot occur in the values you are joining. For phone numbers this is easy, because the character set is tiny: digits, +, spaces, brackets, dashes, dots, occasionally x or ext. A tilde, a pipe, or a caret will never appear. For free-text fields, be more careful and use something deliberately absurd like ~~SLOT1~~.

The general rule: the placeholder has to be impossible in the data, not merely unlikely. Unlikely means it fails on one record in four thousand, which is exactly the kind of error that gets discovered by a customer rather than by you.

replace() takes the first occurrence, and that is the lever

The other half of the repair is removing the national trunk prefix — the leading 0 that UK, German, French and many other national formats put in front of the number and that E.164 drops.

replace() replaces the first occurrence only. Attio documents this explicitly and even gives the example that proves it: replace("aabbcc", "b", "x") = "aaxbcc" — one b changed, the second left alone.

That is the entire reason a leading-character repair is possible. Applied to a number that starts with a zero:

replace("07700900123", "0", "")

The first 0 is the one at the front, so it is the one that goes, and the result is 7700900123. Every other zero in the number survives, because replace() never looked past the first match.

You can do the prefix and the strip in a single pass, since the replacement text does not have to be empty:

replace("07700900123", "0", "+44")

That returns +447700900123 directly. One function call turns a UK local number into E.164.

What replaceAll() does to a phone number

It is worth seeing the wrong version, because the two functions are one letter apart and the failure is silent.

replaceAll() replaces every occurrence. Run the same repair with it:

replaceAll("07700900123", "0", "+44")

Every zero in the number is now +44. The result is +44770+449+44+44123 — not a phone number, not close to one, and not something phoneNumber() will accept. That last part is the only good news here: this particular mistake fails loudly at the conversion step rather than producing a plausible wrong number.

The distinction generalises. Use replace() when position is doing the work and replaceAll() when it is not. Anchoring to the first character of a string is a position job, so it is replace(). Stripping spaces from anywhere in a string is not, so it is replaceAll(). Get them backwards in the first case and you corrupt the value; get them backwards in the second and you fix one space out of three.

Stripping formatting without regex

E.164 has no punctuation, and real phone data is full of it. With no regex in the language, there is no character class to strike out — you remove one literal at a time, chaining replaceAll() calls:

replaceAll(replaceAll(replaceAll(replaceAll({Phone raw}, " ", ""), "-", ""), "(", ""), ")", "")

Spaces, dashes, opening bracket, closing bracket. Add . if your data has dotted numbers. The nesting reads badly and there is no way around that; it is four separate operations because the language has no way to express "any of these four characters."

One caution specific to phone numbers. The common European signature format +44 (0)20 7946 0958 puts the trunk zero *inside brackets* precisely to signal "drop this when calling internationally." Stripping the brackets keeps the zero and gives you +44020794609580, which has a spurious zero in the middle. If that pattern is common in your data, remove the whole (0) as a unit before you strip individual brackets:

replaceAll({Phone raw}, "(0)", "")

Order matters here. That one has to run before the bracket strip, or there will be no (0) left to find.

The single-country repair

Put the pieces together for a workspace whose contacts are overwhelmingly in one country. This is the common case and by far the simpler one.

Normalise, then branch on whether the number already carries a country code, then convert:

if(startsWith(replaceAll(replaceAll({Phone raw}, " ", ""), "(0)", ""), "+"),
  phoneNumber(replaceAll(replaceAll({Phone raw}, " ", ""), "(0)", "")),
  phoneNumber(replace(replaceAll(replaceAll({Phone raw}, " ", ""), "(0)", ""), "0", "+44")))

Three branches of behaviour in one expression: already E.164 and left alone, local format and given a +44, anything else passed through to fail at the conversion so you can see it.

Yes, the normalisation is repeated three times. That is deliberate and it is a rule this series has arrived at the hard way: color:var(--color-text-heading)]">do not point a formula attribute at another formula attribute. Referencing one is unreliable in practice — it returns wrong values with no error, including on rows where the referenced field is blank. Inline the whole expression in every branch, however ugly it reads. The [dateAdd() article documents the case that established this.

Use startsWith() rather than checking the length or hunting for a 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">+ with [contains(). A + can legitimately appear mid-string in badly entered data; only its position at the front means anything.

The country-aware repair

If your contacts span countries, +44 cannot be hard-coded, and this is where the concatenation primitive earns its place.

Assuming a {Country dial code} attribute holding values like +44, +1, +49:

if(startsWith({Phone raw}, "+"),
  phoneNumber({Phone raw}),
  phoneNumber(replace(replace("~1~2", "~1", {Country dial code}),
    "~2", replace(replaceAll({Phone raw}, " ", ""), "0", ""))))

The inner replace(..., "0", "") strips the leading trunk zero; the two outer ones glue the dial code onto the front.

Without the concatenation trick this formula cannot exist. The alternative is an if() ladder with one hard-coded branch per country you support, which is workable at three countries and unmaintainable at twenty — and which has to be edited by hand every time you sell into a new market.

The honest limit: this still requires that somebody knows the country. The formula cannot derive it. A bare national number genuinely does not contain that information, so if {Country dial code} is blank the row cannot be repaired by any formula, and the fallback should be to leave it alone rather than guess. Where a country attribute does not already exist, deriving one from the billing address or the email domain is a separate job, done upstream, and it is guesswork with its own error rate.

Phone attributes are lists

One structural detail that trips people up on the first attempt.

If your source is a text attribute — a form field, an imported column, an enrichment field — this does not apply and you can reference it directly.

If your source is Attio's built-in phone attribute, it is multi-value. Like email addresses and domains, it is a text[], and passing it to a function expecting text produces the error Argument of type text[] attribute is not compatible with expected type (text). Append [0] to target the first value, which Attio treats as the primary one:

phoneNumber({Phone numbers}[0])

There is no 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">any() or map() in the library, so operating on every number on a record means indexing each position by hand. The same constraint the [split() and splitPart() article ran into with email addresses applies here without modification.

A formula cannot repair the attribute it reads

This is the part to be clear-eyed about before building any of the above.

A formula attribute is derived and read-only. It cannot write back. So the output of all this work is not a fixed phone attribute — it is a second phone attribute, sitting next to the original, holding the corrected version while the original stays exactly as wrong as it was.

Which means the broken column is still the one on the record's default layout, still the one a rep clicks, and still the one your dialler or sequencing tool reads through the API. You have not repaired your phone data. You have produced a correct copy of it and left the incorrect original in charge.

That may be enough, and sometimes it genuinely is: a clean E.164 column is useful as an export source, as a dedupe key, and as an audit of how much of your phone data is actually usable. Point count() at it and you have a number for the health of the field, which is a better thing to take to a data-cleanup conversation than an impression.

But if the goal is for the team to dial correct numbers, the formula layer is the wrong layer. The durable fix is at the point of entry — a form that enforces a country selector, an import that normalises before it writes, an automation that populates a real phone attribute once. This is the same law the random() article ended on, pointing the other way: if a value has to be acted on rather than read, it has to be stored, not derived.

Use the formula to find out how bad the problem is. Use something that can write to fix it.

CRM use cases

  • A dialable export column — phoneNumber() over a normalised text field — important because most diallers and SMS tools require E.164 and will silently drop everything else, so the gap between "has a phone number" and "has a number we can call" is invisible until a campaign underdelivers.
  • A phone-data health check — count how many records produce a converted value versus how many hold raw text — important because "we have phone numbers for 80% of contacts" and "we can dial 80% of contacts" are different claims, and only the second one is actionable.
  • Post-import normalisation — the single-country repair on a freshly imported text column — important because import is the one moment when the entire dataset shares a known origin, and therefore a known country.
  • Signature-capture cleanup — the (0) strip — important because email-signature numbers are the most common source of a country code and a trunk prefix coexisting, which is the one input that looks valid and converts wrong.
  • Joining two fields into one label — the replace() concatenation pattern, applied to anything — important because this is the technique's real value, and phone numbers are just the case that forces you to discover it.

Copy-paste formulas

Swap in your own attribute names.

Convert a number that already carries a country code (Phone number output):

phoneNumber({Phone raw})

From Attio's built-in multi-value phone attribute:

phoneNumber({Phone numbers}[0])

Strip common formatting characters (Text output):

replaceAll(replaceAll(replaceAll(replaceAll({Phone raw}, " ", ""), "-", ""), "(", ""), ")", "")

Remove a bracketed trunk prefix before anything else (Text output):

replaceAll({Phone raw}, "(0)", "")

Prepend a fixed country code to a local number (Text output):

replace({Phone raw}, "0", "+44")

Concatenate two attributes (Text output):

replace(replace("~1~2", "~1", {Country dial code}), "~2", {Local number})

Single-country repair, branching on whether the number is already E.164 (Phone number output):

if(startsWith(replaceAll(replaceAll({Phone raw}, " ", ""), "(0)", ""), "+"),
  phoneNumber(replaceAll(replaceAll({Phone raw}, " ", ""), "(0)", "")),
  phoneNumber(replace(replaceAll(replaceAll({Phone raw}, " ", ""), "(0)", ""), "0", "+44")))

Country-aware repair using a stored dial code (Phone number output):

if(startsWith({Phone raw}, "+"),
  phoneNumber({Phone raw}),
  phoneNumber(replace(replace("~1~2", "~1", {Country dial code}),
    "~2", replace(replaceAll({Phone raw}, " ", ""), "0", ""))))

Gotchas

  • A valid number is not an acceptable number. phoneNumber() rejects input on completeness, not correctness. Every other conversion in the library rejects input on correctness. Do not debug this function by staring at a number that looks fine — it probably is fine, and that is not the test it has to pass.
  • The failure mode is undocumented. Attio states that a country code is required but not what happens without one. Establish whether you get an error or a blank on a scratch attribute before you build a view or a filter on top of it.
  • replace() is first-occurrence, replaceAll() is every occurrence. Using replaceAll() for a leading-zero strip turns every zero in the number into the country code. It is one letter and it is the difference between a repair and a corruption.
  • Placeholders must be impossible, not unlikely. The concatenation pattern matches literal text, so a placeholder that can appear inside your data will silently attach the wrong piece on the records that contain it.
  • Order the strips deliberately. (0) has to be removed before individual brackets, or the trunk zero survives in the middle of an otherwise correct E.164 string.
  • Do not reference another formula attribute. Inline the normalisation in every branch. Pointing at a formula attribute returns wrong values with no error, including on rows where the referenced field is empty.
  • The built-in phone attribute is a list. Use {Phone numbers}[0] or you will get a text[] type error. There is no any() or map() to reach the rest.
  • No regex, no concat operator. + is addition. Removing a character class means one replaceAll() per character, and joining strings means the replace() template pattern.
  • The output is a copy. A formula attribute cannot write back to the attribute it reads, so the broken column stays broken and stays primary.

Final thoughts

phoneNumber() looks like the smallest function in the library and it turns out to be the one that asks the hardest question, because it is the only one that can reject data for being incomplete rather than wrong. A phone number is not a string. It is a string plus a country, and CRMs have spent decades storing only the first half because the second half was obvious to everyone in the room at the time.

The function itself is one call. The work is all in getting a string that satisfies it, and that work runs straight into the two things this language does not have — no regex and no concatenation. The regex gap you live with, one replaceAll() at a time. The concatenation gap turns out not to be a gap at all: replace() on a literal template does the job, and once you have that, a great deal more than phone numbers opens up.

Test it before you trust it. Then go fix the data at the point where it enters the system, because a formula can show you the right number and it can never make it the one your team dials.

Next: currency(), checkbox() and rating() together — three conversions that never fail, and why a function that cannot return an error is more dangerous in a live view than one that can.

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

And if you would rather have your phone data, import pipeline and field hygiene 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.