The startsWith() and endsWith() functions in Attio: classifying records by how their text begins and ends
Half of CRM classification is a question about position. Does this domain *end* in .gov? Does this SKU *start* with ENT-? Is this a .edu address, a +44 phone number, a .pdf attachment? In every one of those, the text you are matching means one thing at the edge of a value and something else in the middle.
Attio's contains() has never been able to tell the difference. It answers "is this in there somewhere", which is the right question for a notes field and the wrong one for a domain. As of this month there are two functions that answer the sharper version: startsWith() and endsWith().
They are the simplest of the eight new text functions, and the easiest to get subtly wrong — once because of capitalisation, and once because of a missing dot.
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(), and split()/splitPart().
Table of contents
- What startsWith() and endsWith() do
- Anchoring is the whole point
- Case sensitivity will quietly cost you records
- The suffix trap that lets a lookalike domain through
- Multi-value attributes still need [0]
- The search term does not have to be a literal
- Composing with split(), lower() and or()
- CRM use cases that earn their keep
- Copy-paste formulas
- Gotchas
- Final thoughts
What startsWith() and endsWith() do
Both take a text value and a search term, and return true or false.
startsWith(value, search)
endsWith(value, search)Attio's own examples:
startsWith("Hello World", "Hello") → true
endsWith("report.pdf", ".pdf") → trueThat is the entire surface area. There are no options, no flags, no third argument. What makes them worth writing about is not the syntax — it is that the obvious formula is wrong three times in a row before it is right.
Because they return a boolean, set the output type to Checkbox rather than leaving it on Auto. A checkbox is filterable, sortable and groupable in a view; an Auto column that happens to hold true is a string that only looks like one.
Anchoring is the whole point
Attio already had contains(), and on a first read startsWith() looks like a narrower version of it. That framing is backwards. contains() is the *unanchored* match, and unanchored is usually the weaker question.
Consider a single company record with the domain research.gov.uk and a description field that reads "sold into a .gov agency last year".
| Formula | Result | Is that what you meant? |
|---|---|---|
contains({Description}, ".gov") | true | No — it matched prose |
contains({Domains}[0], ".gov") | true | Not really — .gov.uk is not .gov |
endsWith({Domains}[0], ".gov") | false | Yes |
endsWith({Domains}[0], ".gov.uk") | true | Yes |
Each of those is doing exactly what it says. The difference is that contains() has no opinion about *where* the match happened, and for extensions, suffixes, prefixes and country codes the location is the meaning. A .pdf at the end of a filename is a file type. A .pdf in the middle of one is part of the name.
The practical rule: use contains() when you are searching free text for a mention, and startsWith()/endsWith() when you are classifying a structured value by its edge.
There is still no regex anywhere in Attio's formula library, so these two are the only anchors you get. Matching the middle of a value in a position-aware way means combining them with splitPart() to cut the value down to the piece you care about first.
Case sensitivity will quietly cost you records
Both functions are case-sensitive. Attio documents this, and it is easy to nod past.
endsWith("REPORT.PDF", ".pdf") → false
startsWith("acme.com", "ACME") → falseIn a clean dataset this never comes up. CRM data is not a clean dataset. Domains arrive lowercase from one enrichment provider and title-cased from another. Email addresses typed by humans are capitalised at the whim of an autocorrect. A CSV exported from an old system will happily contain Gmail.com and gmail.com in the same column.
So the default shape of every one of these formulas has lower() on the outside of the value:
endsWith(lower({Domains}[0] ?? ""), ".gov")The failure mode matters more than the rule. A case mismatch does not error — it returns 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">false. Your checkbox column populates on every record, most of the ticks are in the right place, and a silent minority are wrong. It reads as "we just don't have many of those", which is the most expensive kind of wrong a CRM field can be. It is the same shape of failure as an out-of-range index in [splitPart(): the column fills, nothing complains, and the mistake looks like sparse data.
If you want to sanity-check a new classification column, filter the view to the records where it is false and scan them. The eye catches Acme.com sitting in the unmatched pile far faster than it catches its absence from the matched one.
The suffix trap that lets a lookalike domain through
This is the one that survives review, because the formula looks correct and tests correctly on every record you happen to look at.
You want to flag everyone at Acme. The obvious formula:
endsWith(lower({Domains}[0] ?? ""), "acme.com")That is true for acme.com. It is also true for notacme.com, myacme.com, fake-acme.com and anything else a partner, a reseller or a typo-squatter happens to register. endsWith() anchors the *end* of the string, not the start of a label — and notacme.com genuinely does end in acme.com.
The correct test treats the domain boundary explicitly: match the domain exactly, or match a subdomain of it.
or(lower({Domains}[0] ?? "") == "acme.com",
endsWith(lower({Domains}[0] ?? ""), ".acme.com"))The leading dot in the second branch is doing all the work. It is what turns "ends in those characters" into "is under that domain", and it correctly keeps eu.acme.com and shop.acme.com while dropping notacme.com.
The same trap shows up in reverse on prefixes. startsWith({SKU}, "ENT") matches ENT-100 and also matches ENTRY-100. Include the separator — "ENT-" — and the ambiguity disappears.
Whenever the thing you are matching is a *segment* of a structured value rather than a raw string, put the delimiter inside the search term.
Multi-value attributes still need [0]
If you point either function at an email address, a domain or a phone number, you get this:
Argument of type text[] attribute is not compatible with expected type (text)Nothing in the formula looks like a list, which is what makes the message confusing. The cause is that those attributes hold multiple values in Attio, so the reference resolves to text[] and the text functions want text.
Append [0] to the attribute reference:
endsWith(lower({Email addresses > Email address}[0]), "@acme.com")[0] is the primary value — Attio treats the first entry on a record as primary. The cost is that the formula now genuinely only looks at the primary. There is no any() or map() in the library, so covering a second address means writing it out:
or(endsWith(lower({Email addresses > Email address}[0] ?? ""), "@acme.com"),
endsWith(lower({Email addresses > Email address}[1] ?? ""), "@acme.com"))That works, and it stops being reasonable at about three. If you need a real any-of-these check across an open-ended list, you are past what formula attributes do well.
The search term does not have to be a literal
Almost every example — Attio's and everyone else's — passes a quoted string as the second argument. It takes an attribute just as happily, and that is where these two functions stop being a tidier contains() and start doing something you could not do before.
A naming-convention check, where deals are supposed to be named after their company:
startsWith(lower({Name} ?? ""), lower({Company > Name} ?? ""))That is a data-quality column. Every deal whose name has drifted from its account shows up as false, and the view is the cleanup list. No hardcoded values, so it keeps working as the account list grows.
Where the two values don't line up for a direct anchor, cut one down first. Comparing a person's email domain to the domain on their linked company is a free consistency check that catches people attached to the wrong account after an import:
lower(splitPart({Email addresses > Email address}[0] ?? "", "@", 1)) == lower({Company > Domains}[0] ?? "")Remember to lower() both sides. Case-sensitivity applies to the search term exactly as it applies to the value, and an attribute is far more likely to carry stray capitals than a string you typed yourself.
Composing with split(), lower() and or()
These functions are small on their own. They earn their place in combinations.
With splitPart(), to anchor on a piece rather than the whole. A website attribute usually holds https://www.acme.com/pricing, which means startsWith({Website}, "acme") is false on every record. Strip the protocol and take the host first:
endsWith(splitPart(replaceAll(replaceAll(lower({Website} ?? ""), "https://", ""), "http://", ""), "/", 0), ".edu")With or(), for a short list of alternatives. Two or three values is the sweet spot:
or(endsWith(lower({Domains}[0] ?? ""), ".gov"),
endsWith(lower({Domains}[0] ?? ""), ".gov.uk"),
endsWith(lower({Domains}[0] ?? ""), ".mil"))Past three, extract the piece once with splitPart() and hand it to contains() against an array instead — one comparison against a list beats five endsWith() calls, and the list is easier to edit later.
With if(), to produce a label rather than a tick. Prefix routing is the classic:
if(startsWith({SKU} ?? "", "ENT-"), "Enterprise",
if(startsWith({SKU} ?? "", "PRO-"), "Pro",
if(startsWith({SKU} ?? "", "STD-"), "Standard", "Unclassified")))Note the "Unclassified" at the bottom rather than an empty string. A visible bucket for the things that matched nothing is what turns the column into a report on your own naming discipline.
With left() and right(), which are the same idea by another route. startsWith(v, s) is equivalent to left(v, length(s)) == s. Use the named function — it is clearer and it cannot drift out of sync when the search term changes length.
CRM use cases that earn their keep
Public-sector and academic flags. .gov, .gov.uk, .edu, .ac.uk, .org — segments that often carry different pricing, procurement cycles and legal terms, and that nobody wants to tag by hand.
Named-account detection. Everyone whose email is under a target domain, including subdomains, without maintaining a list of people.
SKU and product-line routing. Turn a prefix convention that lives in people's heads into a groupable column, and get a count of the records that follow no convention at all.
Phone-number geography. startsWith({Phone numbers}[0] ?? "", "+44") gives a region column from data you already have, without an enrichment credit.
Attachment and document triage. endsWith(lower({File name} ?? ""), ".pdf") separates signed contracts from working notes when the file name is all you have.
Test and duplicate cleanup. Records named Test, test account, Acme (copy) — a checkbox that catches both ends of the junk-naming problem is the fastest way to find what a migration left behind.
Campaign and landing-page attribution. Whether a lead's first touch started in the blog, the pricing page or a paid landing page, read straight off the URL prefix.
Naming-convention QA. The startsWith({Name}, {Company > Name}) check above, run as a saved view that the ops owner clears weekly.
Copy-paste formulas
Government domain, including the UK form:
or(endsWith(lower({Domains}[0] ?? ""), ".gov"), endsWith(lower({Domains}[0] ?? ""), ".gov.uk"))Everyone at a target domain, subdomains included, lookalikes excluded:
or(lower({Domains}[0] ?? "") == "acme.com", endsWith(lower({Domains}[0] ?? ""), ".acme.com"))Academic email address:
endsWith(lower({Email addresses > Email address}[0] ?? ""), ".edu")Product line from a SKU prefix:
if(startsWith({SKU} ?? "", "ENT-"), "Enterprise", if(startsWith({SKU} ?? "", "PRO-"), "Pro", "Standard"))UK phone number:
startsWith({Phone numbers}[0] ?? "", "+44")PDF attachment:
endsWith(lower({File name} ?? ""), ".pdf")Blog-sourced lead:
startsWith(lower({Landing page} ?? ""), "https://www.craftt.io/blog/")Likely test or duplicate record:
or(startsWith(lower({Name} ?? ""), "test"), endsWith(lower({Name} ?? ""), "(copy)"))Deal name follows the account-name convention:
startsWith(lower({Name} ?? ""), lower({Company > Name} ?? ""))Person's email domain matches their linked company:
lower(splitPart({Email addresses > Email address}[0] ?? "", "@", 1)) == lower({Company > Domains}[0] ?? "")Website host ends in a given TLD, protocol and path stripped:
endsWith(splitPart(replaceAll(replaceAll(lower({Website} ?? ""), "https://", ""), "http://", ""), "/", 0), ".io")Gotchas
Both are case-sensitive. lower() the value, and lower() the search term too when it comes from an attribute rather than from your keyboard.
A case mismatch returns false, not an error. The column fills in, the wrong records sit quietly in the false pile, and it reads as sparse data. Filter for false and scan before you trust it.
endsWith(domain, "acme.com") also matches notacme.com. Anchor on the label boundary: compare the exact domain with ==, or use ".acme.com" with the leading dot.
startsWith(sku, "ENT") also matches ENTRY-100. Put the separator inside the search term.
Multi-value attributes are text[] and will be rejected. Append [0] to the attribute reference. It targets the primary value only, and with no any() or map() in the library, covering more means OR-ing indexes by hand.
Website and URL attributes carry a protocol. https://www. sits in front of the thing you are trying to match, so a prefix test on a bare domain fails on every record. Strip it with replaceAll() first.
Blanks propagate. An empty attribute can blank the whole expression rather than returning false. Put ?? "" on the attribute, not on the end of the formula, so the comparison still runs and the record stays in your filters.
There is no wildcard. These are anchors, not patterns. A match in the middle of a value is contains(), or splitPart() down to the segment first.
Force the output to Checkbox. A boolean left on Auto is a string, and it will sort and group like one.
Three formula attributes deep is still the ceiling, and referencing another formula attribute remains unreliable in practice. If a label column needs a domain that another formula already computes, re-derive it inline rather than pointing at it.
Final thoughts
startsWith() and endsWith() do not unlock anything dramatic. They answer a question your CRM could not previously ask in a single field, and the question is a mundane one: does this value belong to that category, judged by how it begins or ends.
What is worth carrying away is that the anchoring is a feature and not a limitation. contains() gives you a loose match that is right most of the time, and "most of the time" is how a CRM ends up with a segment column nobody trusts. An anchored match is either right or visibly wrong, and visibly wrong is fixable.
Then add the dot. Every workspace that rolls out a named-account flag with endsWith() gets a lookalike domain in the list eventually, and nobody finds it by reading the formula — they find it when someone asks why a competitor's employee is in the customer segment.
Sitting on fields you can't classify because the values arrived from four tools in four shapes? Get a free Attio audit and we'll map what's actually in them — or see how we build classification into the schema from day one 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.