The left() and right() functions in Attio: cutting text by position when your data will not promise a length
left() and right() are the easiest functions in Attio's text batch to understand and the easiest to misuse. Give them a value and a number, get that many characters off the front or the back. There is no syntax to get wrong.
The trouble is what they assume. Every other text function in Attio finds something — a delimiter, a substring, a prefix — and cuts relative to what it found. left() and right() find nothing. They count. Which means they are correct exactly as often as your data is the same shape on every record, and CRM data almost never is.
So this piece spends as much time on when *not* to use them as on how. It also covers the one thing they are genuinely irreplaceable for: Attio has no mid() and no substring(), and these two are how you build one.
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(), and lower()/upper().
Table of contents
- What left() and right() do
- Counts, not indexes
- The two edges that never throw an error
- Attio has no mid function
- Cutting characters off instead of keeping them
- Four places left() looks right and is not
- Where fixed-length cutting genuinely belongs
- CRM use cases that earn their keep
- Copy-paste formulas
- Gotchas
- Final thoughts
What left() and right() do
Two arguments each: the text, and how many characters you want.
left(value, count)
right(value, count)Attio's own examples:
left("Hello World", 5) → "Hello"
right("Hello World", 5) → "World"Set the output type to Text. These always return text, even when every character in the result happens to be a digit, so left("2026-09-21", 4) is the string "2026" and not the number 2026. That matters the moment you try to compare the column to a number or sort it as one.
Counts, not indexes
This is the first thing to get straight, because the function next to these in the library works the other way.
13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">splitPart() takes an index and [counts from zero — splitPart("a,b,c", ",", 0) is "a". left() and right() take a count and count from one, in the ordinary human sense: left(value, 1) gives you one character, the first one.
There is no version of this you can reason out from the signatures; the two conventions simply sit side by side in the same library. The practical consequence is that a formula mixing both is where off-by-one bugs live:
left(splitPart({SKU} ?? "", "-", 1), 3)The 1 and the 3 mean completely different things. The 1 is the second segment of the SKU. The 3 is three characters. Read every number in a nested text formula and ask which kind it is before you trust the result.
The two edges that never throw an error
Both functions are total. There is no input that errors, which is convenient right up until it is the problem.
| Situation | Result |
|---|---|
count is larger than the length of the value | The whole value, unchanged |
count is zero or negative | Empty text |
| The value is blank | Empty text |
The top row is a genuine gift. It means left({Description} ?? "", 80) is a safe truncation: long values get cut, short values come back whole, nothing errors, and you never have to test the length first. That single property is why left() is the right tool for previews and summaries.
The bottom rows are the trap, and they only bite when the count is computed rather than typed. A literal 13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">left(value, 4) can never go negative. left(value, length(value) - 4) goes negative the moment a value is three characters long, and the column quietly fills with empty text on exactly those records. No error, no warning — the same silent-failure class as the [one-sided lower() comparison and the out-of-range splitPart() index.
If you are subtracting inside the count, guard it:
if(length({Code} ?? "") > 4, left({Code} ?? "", length({Code} ?? "") - 4), {Code} ?? "")Attio has no mid function
Spreadsheets give you three slicers: LEFT, RIGHT and MID. Attio ships two. There is no mid(), no substring(), no slice() — and no regex anywhere in the formula language to fall back on.
You rebuild the third one by nesting the two you have. The trick is to work from the outside in: left() sets where the window ends, right() sets where it starts.
To take characters 4 through 6 of a value:
right(left({Code} ?? "", 6), 3)Read it inside out. left(..., 6) keeps everything up to and including the sixth character. right(..., 3) then keeps the last three of those six, which are characters four, five and six.
The general form, for n characters starting at position p (counting from one):
right(left(value, p + n - 1), n)So characters 5 to 9 — five characters starting at position five — is right(left(value, 9), 5).
Worth knowing the arithmetic, but worth saying plainly too: if you find yourself computing windows like this, check first whether the value has a delimiter in it. A nested left/right window is the correct tool for a fixed-width format and the wrong tool for anything that merely *usually* looks fixed-width.
Cutting characters off instead of keeping them
13px] bg-[color:var(--color-bg-muted)] border border-[color:var(--color-border)] px-1.5 py-0.5 rounded">left() and right() keep what you ask for. Half the time what you actually want is the opposite — drop a known prefix or a known suffix and keep the rest. That is the same two functions plus [length():
right(value, length(value) - n) drops the first n characters
left(value, length(value) - n) drops the last n charactersThis is how you strip a fixed prefix that replaceAll() would be dangerous on. If your deal IDs look like DEAL-4471 and you want the number, replaceAll({Deal ID}, "DEAL-", "") works — but only because DEAL- is unlikely to recur inside the string. For a two-character prefix the risk of hitting it again mid-value is real, and position is safer than search:
right({Deal ID} ?? "", length({Deal ID} ?? "") - 5)It is also the rigorous way to cut a legal suffix off a company name, which the match key article promised to come back to. replaceAll(name, " inc", "") removes inc wherever it appears. Anchoring with endsWith() and cutting by length removes it only at the end, where it means something:
if(endsWith(lower({Name} ?? ""), " inc"),
left(lower({Name} ?? ""), length({Name} ?? "") - 4),
lower({Name} ?? ""))Four characters, because inc is a space plus three letters. Count the separator. This costs one if() per suffix, which is why most workspaces run the blunt replaceAll() chain instead — but if the key is ever going to be read by a person, this is the version that survives review.
Four places left() looks right and is not
Every one of these is a real formula somebody has written, and every one of them is a delimiter problem wearing a fixed-width costume.
Postcodes. left({Post code}, 4) for a UK outward code works on SW1A 1AA and fails on M1 1AE, where it returns "M1 1" — the district, a space, and the first digit of the inward code. Outward codes are two to four characters. The delimiter is the space, so use it: splitPart({Post code} ?? "", " ", 0). The same applies to any national format where the length varies.
color:var(--color-text-heading)]">Domains and TLDs. right({Domains}[0], 4) gets you .com and .org and then hands you "o.uk" for acme.co.uk and "e.io" for acme.io. TLDs are not four characters. If you want to test one, [endsWith() is the anchored comparison you actually meant.
Email domains. right({Email addresses}[0], 10) is never the answer. splitPart({Email addresses > Email address}[0] ?? "", "@", 1) is, and it is correct on every record regardless of length.
color:var(--color-text-heading)]">Date parts. left(formatDate({Closed On}, "yyyy-MM-dd"), 7) genuinely returns "2026-09", and it is still the wrong formula, because [formatDate() will give you that directly with formatDate({Closed On}, "yyyy-MM"). Slicing a string you just formatted means you formatted it wrong. The only date case where left() earns its place is cutting a date that arrived as *text* from an import and was never a date attribute at all.
The pattern across all four: if the thing you are cutting is separated by a character, cut on the character. Reach for position only when there is no character to cut on.
Where fixed-length cutting genuinely belongs
The honest list is short, which is fine — these are precision tools, not general ones.
Genuinely fixed-width codes. Internal SKUs, batch numbers, account codes, ISO country and currency codes, anything your own systems generate to a spec. left({SKU} ?? "", 3) as a product-family bucket is correct because you control the format.
First-letter grouping. upper(left({Name} ?? "", 1)) gives you an A–Z column to group a company list by. One character is always safe — it is only ever the whole value or less.
Safe truncation for a preview column. left({Notes} ?? "", 100). Long values cut, short values whole, nothing errors. Note that you cannot mark the cut: Attio has no concat(), so there is no supported way to append an ellipsis inside the formula.
Masking. right({Phone numbers}[0] ?? "", 4) is the last four digits, the standard way to show enough of an identifier to confirm it without exposing it. Works on any length, because you are counting from the end and the end is where the meaningful digits are.
Rebuilding the middle. The nested form above, when the format really is fixed.
Cutting by computed length. The prefix and suffix removals above, guarded.
Notice what these have in common: either the count is 1, or the count comes from a format you control, or the count is a maximum rather than an exact cut. Those are the three safe shapes.
CRM use cases that earn their keep
- Product family from an internal SKU.
left({SKU} ?? "", 3), forced to Text, then group the deals view by it. Reporting by product line without a new attribute on every record. - color:var(--color-text-heading)]">Alphabetical bucket for a company list.
upper(left({Name} ?? "", 1))— pairs with [upper()soacmeandAcmeland in the same bucket. - Note preview in a list view.
left({Description} ?? "", 100)gives a readable column in a table where the full field would be unusable. - Last four of a phone number for support verification:
right({Phone numbers}[0] ?? "", 4). - Strip a system prefix from an imported ID.
right({Legacy ID} ?? "", length({Legacy ID} ?? "") - 4)turnsCRM-88213into88213without a search-and-replace that could hit the body of the value. - Anchored legal-suffix removal on a company match key, the
endsWith()plusleft()form above, when the key needs to stay human-readable. - Year from a text date that never became a date attribute.
left({Imported date} ?? "", 4)on an ISO string, as a stopgap while the import gets fixed.
Copy-paste formulas
First three characters as a category bucket:
left({SKU} ?? "", 3)Last four characters of an identifier:
right({Phone numbers}[0] ?? "", 4)Alphabetical first-letter bucket, case-normalised:
upper(left({Name} ?? "", 1))Safe truncation for a preview column:
left({Description} ?? "", 100)Characters 4 to 6 of a fixed-width code:
right(left({Code} ?? "", 6), 3)The general window — n characters starting at position p:
right(left({Code} ?? "", p + n - 1), n)Drop the first four characters:
right({Legacy ID} ?? "", length({Legacy ID} ?? "") - 4)Drop the last four characters, guarded against short values:
if(length({Code} ?? "") > 4, left({Code} ?? "", length({Code} ?? "") - 4), {Code} ?? "")Anchored legal-suffix removal for a readable match key:
if(endsWith(lower({Name} ?? ""), " inc"),
left(lower({Name} ?? ""), length({Name} ?? "") - 4),
lower({Name} ?? ""))Flag values that were actually truncated, so you know how many the preview is hiding:
length({Description} ?? "") > 100Gotchas
- Counts, not indexes.
left()andright()count from one;splitPart()indexes from zero. In a nested formula, work out which kind every number is before you trust it. - A count larger than the value returns the whole value. Convenient for truncation, but it means a cut that is too generous fails invisibly — the column looks populated and nothing was cut.
- A count of zero or less returns empty text. Never an error. Any
length(value) - ncount empties the column on every record shorter thann. - Guard computed counts with an
if(). If the count is arithmetic rather than a literal, assume some record will make it negative, because some record always does. - The result is text.
left("2026-09-21", 4)is"2026", not the number. Force the output type to Text and do not expect numeric sorting. - There is no
mid()orsubstring(). Build it asright(left(value, p + n - 1), n), and check whether a delimiter would do the job better first. - There is no
concat(). You can truncate but you cannot append an ellipsis to the result. - Multi-value attributes are
text[]. Phone numbers, email addresses and domains reject these functions with *"Argument of type text[] attribute is not compatible with expected type (text)"*. Append[0]to target the primary value. - Guard blanks at the attribute.
left({Name} ?? "", 3), neverleft({Name}, 3) ?? "". - color:var(--color-text-heading)]">Normalise before you slice, not after.
upper(left({Name} ?? "", 1))andleft(lower({Name} ?? ""), 3)both work, but keeping [lower()innermost is the habit that stays correct when the formula grows. - Do not point at another formula attribute. If you want to slice a match key you already built, re-derive the expression inline. Chained formula references return wrong values with no error.
Final thoughts
left() and right() are the functions to reach for last. Not because they are unreliable — they are the most predictable pair in the library, with no errors and no edge cases you cannot see coming — but because the thing they need, a guaranteed length, is the thing CRM data is worst at providing. Almost every field that looks fixed-width is one import away from not being.
Where they are irreplaceable is the gap Attio left in the middle of its text library. There is no mid(), and until there is, right(left(value, p + n - 1), n) is the substring operation, and right(value, length(value) - n) is how you drop a prefix. Those two patterns are worth keeping somewhere you can find them, because you will not derive them under time pressure.
And if your formulas are full of character counts, that is usually a signal about the data rather than the formula. A field that needs slicing on every read is a field that should have been two attributes at import time — which is the sort of thing a workspace audit exists to catch.
Next: date() and timestamp(), and the difference between a date attribute and a date-shaped string that has been quietly costing you filters.
Want the schema fixed rather than worked around one formula at a time? Our AI-native Attio sprint covers the data model, the formulas and the import discipline together.
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.