The split() and splitPart() functions in Attio: pulling the email domain out without leaving the CRM
Somewhere in your workflow there is an export. Somebody pulls people out of the CRM, opens a spreadsheet, writes a formula to chop everything after the @, groups by domain, and pastes the answer into a slide. Then a week passes and the data is stale again.
Attio shipped eight new text functions this month, and splitPart() is the one that ends that ritual. Everything the spreadsheet step was doing — email domains, first names, SKU prefixes, URL segments — becomes a column that is simply always right.
Two catches nobody warns you about: the index starts at zero, and the attribute you most want to split is a list, not text. Both are five-second fixes once you know them, and both look like the function is broken until you do.
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(), and exp()/log().
Table of contents
- What split() and splitPart() do
- splitPart() counts from zero
- The email domain formula everyone rebuilds in a spreadsheet
- Multi-value attributes are lists, and lists break text functions
- split() alone cannot be a formula result
- Composing with the rest of the library
- CRM use cases that earn their keep
- Copy-paste formulas
- Gotchas
- Final thoughts
What split() and splitPart() do
Both cut a text value into pieces at a delimiter. They differ in what they hand back.
split("a,b,c", ",")[0] → "a"
splitPart("sarah@attio.com", "@", 1) → "attio.com"split(value, delimiter) returns the whole list of parts. splitPart(value, delimiter, index) returns exactly one of them.
In practice you will write splitPart() almost every time. A formula attribute has to produce a single value, and splitPart() is the version that already does. split() is what you reach for when you want to ask a question *about* the pieces rather than take one — how many are there, does any of them match — and even then it has to be wrapped in something else.
The delimiter is text, not a pattern. There is no regular-expression support anywhere in Attio's formula library, so "@", ",", " ", "-" and "://" all work, and "\s+" does not mean anything.
splitPart() counts from zero
This is the single most common way to get a blank column. Attio's text functions are zero-indexed: the first piece is 0, the second is 1.
Here is the same address cut two ways, so you can see it settle:
| Formula | Result |
|---|---|
splitPart("sarah@acme.co.uk", "@", 0) | sarah |
splitPart("sarah@acme.co.uk", "@", 1) | acme.co.uk |
splitPart("sarah@acme.co.uk", ".", 0) | sarah@acme |
splitPart("sarah@acme.co.uk", ".", 1) | co |
splitPart("sarah@acme.co.uk", ".", 2) | uk |
splitPart("sarah@acme.co.uk", ".", 3) | *(empty)* |
Two things to take from that table.
First, the domain is index 1, not 2. If you came from a spreadsheet where text functions count from one, every formula you port will be one place to the left, and it will look plausible — you'll get mailbox names where you expected domains, which reads as "the formula grabbed the wrong side" rather than "the index is off".
Second, and worse: color:var(--color-text-heading)]">an out-of-range index returns empty text, not an error. Index 3 above doesn't complain. It just hands back nothing. So a formula that is silently wrong on a subset of your records — the ones with fewer parts than you assumed — populates perfectly everywhere else and leaves a scatter of blanks you'll read as missing data. This is the same failure class as the natural-log trap in the [exp() and log() article: the column fills, nothing errors, and the mistake is invisible until someone acts on it.
Before you trust a splitPart() column, filter the view for empty and look at what's in there. If those records have a good reason to be blank, fine. If they don't, your index is wrong.
The email domain formula everyone rebuilds in a spreadsheet
Here it is, and it is the whole reason this function matters:
splitPart({Email addresses > Email address}[0], "@", 1)Put that on the People object, force the output type to Text, and every person in the workspace now has a company domain attached to them — permanently, with no export.
What that one column unlocks:
- Group your people list by domain and see, instantly, how many contacts you actually hold at each account.
- Spot people whose email domain doesn't match their linked company — the ones who changed jobs and nobody updated.
- Flag personal addresses so they don't get counted as account coverage.
- Match inbound signups to existing companies, which is the manual step that eats the first ten minutes of every lead-routing process.
That last one is worth dwelling on. Most workspaces discover an inbound lead is already a customer *after* somebody has emailed them a cold intro. A domain column doesn't fix routing on its own, but it makes the collision visible in a view rather than in an apology.
The free-email flag is the natural companion:
contains(
["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com"],
lower(splitPart({Email addresses > Email address}[0], "@", 1))
)contains() takes a text array as its first argument, so you can test membership against a hand-written list without nesting five or() calls. Force the output to Checkbox and it becomes a filter. The lower() wrapper is not optional — the new startsWith(), endsWith() and comparison behaviour in Attio is case-sensitive, and Gmail.com will not match gmail.com.
Multi-value attributes are lists, and lists break text functions
This is the part that will cost you twenty minutes if nobody tells you.
Write the obvious version of that formula and Attio refuses it:
splitPart({Email addresses > Email address}, "@", 1)Argument of type text[] attribute is not compatible with expected type (text)Email addresses, domains and phone numbers are multi-value attributes in Attio. A person can have three email addresses, so the attribute is a list of text — text[] — and every text function in the library wants a single text. The error names the problem precisely and still manages to be baffling, because nothing in the formula looks like a list.
The fix is one suffix on the attribute reference:
splitPart({Email addresses > Email address}[0], "@", 1)[0] takes the first entry. Attio treats the first email on a record as the primary one, so that is almost always the value you meant. The documentation only ever shows [n] applied to the result of a split(), but it works directly on a multi-value attribute reference too — confirmed live in a production workspace this month.
Worth being clear about the limitation you have just accepted: you are now looking at one email address, not all of them. There is no any() and no map() in Attio's formula library, so "does this person have *any* address at acme.com" cannot be expressed in general. If you genuinely need to check several, you have to OR the indexes by hand:
or(
endsWith(lower({Email addresses > Email address}[0]), "@acme.com"),
endsWith(lower({Email addresses > Email address}[1]), "@acme.com")
)That covers the first two and stops. It is honest and it is ugly, and for most CRM work the primary address is the right answer anyway — but decide that deliberately rather than discovering it later.
split() alone cannot be a formula result
Try to save a formula whose result is split({Tags}, ",") and Attio tells you a formula can't return a list. Which is correct: an attribute holds a value, and this is several.
So split() always appears inside something. Two shapes are useful.
Index into it, which is just a longer way of writing splitPart():
split({Full name}, " ")[0]Or — the reason split() exists at all — reduce the list to a single fact about it:
count(split({Tags}, ","))That counts the comma-separated values inside a text field. It is the cheapest data-quality check there is on any field that arrived from a CSV import as a jammed-together string: how many things are crammed in here? Filter for 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">> 1 and you've found every record where a migration flattened a multi-select into text. Pair it with [count() and unique() on the properly-structured attributes and you can measure how much of your import actually landed cleanly.
One caution on that: count(split({Tags}, ",")) on an empty field is not reliably zero — a split of empty text still produces one (empty) part in most implementations. Guard it if the number feeds anything that matters:
if(length({Tags} ?? "") == 0, 0, count(split({Tags}, ",")))Composing with the rest of the library
splitPart() becomes far more useful once you stop treating it as a one-shot.
Clean before you cut. URLs arrive with protocols and trailing paths. Strip, then split:
splitPart(replaceAll(replaceAll({Website}, "https://", ""), "http://", ""), "/", 0)That gives you the bare host from any URL shape, using replaceAll() to normalise first. Add another replaceAll() for "www." if you want it to match enrichment domains.
Split twice to pull a value out of a query string. The second cut ends the first one:
splitPart(splitPart({Landing page}, "utm_source=", 1), "&", 0)Everything after utm_source=, then everything before the next &. Attribution source on the record, from the URL your form captured. A multi-character delimiter is doing the work here — it's a text match, not a single character, which is what makes this pattern possible at all.
color:var(--color-text-heading)]">Cut, then classify. splitPart() produces text, so it feeds straight into [if():
if(
contains(["gmail.com", "yahoo.com", "outlook.com"],
lower(splitPart({Email addresses > Email address}[0], "@", 1))),
"Personal",
"Work"
)color:var(--color-text-heading)]">Cut, then measure. It also feeds [length(), which gives you cheap plausibility checks — a two-character "first name" is usually an initial, and an initial is usually a bad merge field:
length(splitPart({Full name} ?? "", " ", 0)) <= 2CRM use cases that earn their keep
Account coverage by domain. The domain column plus a grouped view tells you how many named contacts you hold at each account. Under two is a single point of failure on every deal in that pipeline, and it's the kind of risk that never surfaces because nothing in the CRM is shaped to show it.
Personal-email hygiene on inbound. Free-email signups convert differently and route differently. A Checkbox that says so, computed the moment the record is created, is worth more than a field somebody is supposed to fill in.
First-name merge fields that don't embarrass you. splitPart({Full name}, " ", 0) gives you a usable greeting from the single-field name that every form and every enrichment tool hands over. Add the length check above and you can filter out the records where it would produce "Hi J.".
Migration damage assessment. Run count(split({field}, ",")) across the text fields a CSV import created and you get an immediate map of what got flattened. Most migration cleanups start with somebody scrolling; this starts with a filter.
SKU and code prefixes. splitPart({SKU}, "-", 0) turns a product code into a product family, which is the grouping your revenue-by-line-of-business report actually needs.
Referral and campaign source. The two-stage query-string split above pulls utm_source, utm_campaign or anything else out of a stored landing-page URL, so attribution lives on the record rather than in an analytics tool nobody in sales opens.
Copy-paste formulas
Email domain, safely (People):
splitPart({Email addresses > Email address}[0] ?? "", "@", 1)Free-email checkbox:
contains(["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com", "aol.com", "proton.me"],
lower(splitPart({Email addresses > Email address}[0] ?? "", "@", 1)))First name for merge fields:
splitPart({Full name} ?? "", " ", 0)Bare host from any website URL:
replaceAll(splitPart(replaceAll(replaceAll({Website} ?? "", "https://", ""), "http://", ""), "/", 0), "www.", "")UTM source from a stored landing page:
splitPart(splitPart({Landing page} ?? "", "utm_source=", 1), "&", 0)Number of values jammed into an imported text field:
if(length({Tags} ?? "") == 0, 0, count(split({Tags}, ",")))Product family from a SKU:
splitPart({SKU} ?? "", "-", 0)Does the primary email match the linked company's domain:
lower(splitPart({Email addresses > Email address}[0] ?? "", "@", 1)) == lower({Company > Domains}[0] ?? "")Gotchas
The index starts at zero. The domain is part 1. Everything ported from a one-indexed spreadsheet will be one place to the left and will look almost right.
An out-of-range index returns empty text, not an error. Your formula will populate for most records and silently blank for the rest. Filter for empty and inspect before you trust the column.
Multi-value attributes are text[] and will be rejected. Append [0] to the attribute reference. This applies to email addresses, domains and phone numbers — the three attributes you most want to split.
[0] means "primary only". There is no any() or map(), so a check across every value on a record has to be written out index by index, or not at all.
split() can't be the result. A formula attribute holds one value. Index into the split, or wrap it in count().
The delimiter is literal text, not a pattern. No regex, anywhere in the library. Multi-character delimiters work; character classes don't exist.
Case matters. lower() the result before comparing domains or testing membership, because the new text comparisons are case-sensitive and enrichment sources are inconsistent about capitalisation.
Blanks propagate. A splitPart() on an empty attribute gives you empty output, and nesting it inside something numeric gets messy fast. Use ?? "" at the attribute, not at the end.
Force the output type. A domain column left on Auto is still text, but a count(split(...)) left on Auto can sort alphabetically, which puts 10 before 9.
Three formula attributes deep is still the ceiling, and referencing another formula attribute remains unreliable in practice. If you want a domain *and* a free-email flag, re-derive the splitPart() inside the second formula rather than pointing at the first one.
Final thoughts
splitPart() is not a clever function. It cuts a string at a character — the most ordinary operation in computing, and one that Attio's formula library went without until this month.
What makes it worth an article is where the missing piece was sitting. Every workspace had a workaround for it, and every workaround was an export. The domain that identifies an account, the first name that goes in a merge field, the campaign source that explains where a lead came from — all of that already lived in the CRM, in fields nobody could cut. Now they can, and the answer updates itself.
Learn the two traps — count from zero, and [0] on anything multi-value — and you can write these in under a minute each. The rest is deciding which spreadsheet you're never opening again.
Text data arriving from three tools in three shapes? Get a free Attio audit and we'll find what's actually in those fields — or see how we build a clean data layer from the start in an 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.
Two ways in. Pick the friction that fits.