Excel Text Functions LEFT, RIGHT, MID and CONCAT in pandas
Excel's text functions exist because a formula operates on one value at a time. pandas operates on whole columns, so LEFT, RIGHT and MID collapse into ordinary slicing and CONCATENATE becomes an operator — and the string methods that have no spreadsheet equivalent, like splitting a column into several or applying a regular expression, come along for free. This guide is part of Excel Formula Equivalents in pandas.
Prerequisites
pip install pandas openpyxl
import pandas as pd
records = pd.DataFrame({
"Reference": ["INV-2026-000412", "INV-2026-000413", "CRN-2026-000044", "INV-2025-009871"],
"Customer": [" Acme Ltd ", "Brightline PLC", "cobalt systems", None],
"Contact": ["ana@acme.example", "ben@brightline.example", None, "dev@cobalt.example"],
"Postcode": ["SW1A 1AA", "M1 4BT", "EH8 9YL", "BS1 5TR"],
}).astype({"Reference": "string", "Customer": "string", "Contact": "string", "Postcode": "string"})
Casting to the nullable string dtype is worth doing up front: it keeps missing values as pd.NA
rather than float('nan'), which makes the string methods behave consistently.
LEFT, RIGHT and MID become slicing
# =LEFT(A2, 3)
records["Doc_Type"] = records["Reference"].str[:3]
# =RIGHT(A2, 6)
records["Serial"] = records["Reference"].str[-6:]
# =MID(A2, 5, 4)
records["Year"] = records["Reference"].str[4:8]
print(records[["Reference", "Doc_Type", "Year", "Serial"]])
One syntax replaces three functions, and it composes: records["Reference"].str[4:8].astype(int)
turns the extracted year into a number in the same expression. The slice is also forgiving in a way
MID is not — asking for more characters than the string has returns what exists rather than an error.
Where the position is not fixed, str.split is more robust than counting characters:
parts = records["Reference"].str.split("-", expand=True)
parts.columns = ["Doc_Type", "Year", "Serial"]
records[["Doc_Type", "Year", "Serial"]] = parts
That is Text to Columns in one line, and unlike the fixed-width version it survives a reference format whose serial number grows a digit.
CONCATENATE, TEXTJOIN and the ampersand
# =A2 & " / " & D2
records["Label"] = records["Reference"] + " / " + records["Postcode"]
# =TEXTJOIN(" | ", TRUE, A2, B2, D2) — skipping blanks
records["Summary"] = records[["Reference", "Customer", "Postcode"]].apply(
lambda row: " | ".join(part for part in row if pd.notna(part)), axis=1
)
# str.cat with a placeholder rather than dropping the row's result
records["Joined"] = records["Reference"].str.cat(records["Customer"], sep=" — ", na_rep="(no name)")
print(records[["Label", "Joined"]])
The + operator behaves like CONCATENATE: if any part is missing, the whole result is missing.
str.cat with na_rep behaves like TEXTJOIN with its ignore-empty flag, supplying a placeholder
instead. Choosing between them is a decision about the report — a blank customer name that silently
blanks the whole label is rarely what anyone wants.
TRIM, UPPER, PROPER and their relatives
records["Customer_Clean"] = (
records["Customer"]
.str.strip() # =TRIM
.str.replace(r"\s+", " ", regex=True) # TRIM also collapses internal runs
.str.title() # =PROPER
)
records["Postcode_Norm"] = records["Postcode"].str.upper().str.replace(" ", "", regex=False)
print(records[["Customer", "Customer_Clean", "Postcode", "Postcode_Norm"]])
str.strip() alone removes leading and trailing whitespace; Excel's TRIM also collapses runs of
internal spaces, which is the second call above. Reproducing both is worth the extra line because
"Acme Ltd" and "Acme Ltd" are different keys to every join and group-by that follows.
str.title() is close to PROPER but not identical — it capitalises after any non-letter, so
"o'brien" becomes "O'Brien" in both, while "acme (uk) ltd" differs in how the parenthesised
part is handled. For customer names, a lookup of known spellings usually beats any case function.
FIND, SEARCH and SUBSTITUTE
# =IFERROR(FIND("-", A2), 0) — position of a substring
records["Dash_At"] = records["Reference"].str.find("-")
# =SEARCH is case-insensitive; the pandas equivalent is a case-insensitive regex
records["Has_Inv"] = records["Reference"].str.contains("inv", case=False, na=False)
# =SUBSTITUTE(A2, "-", "/")
records["Slashed"] = records["Reference"].str.replace("-", "/", regex=False)
# Extract with a pattern — no Excel equivalent short of nested FIND calls
extracted = records["Reference"].str.extract(r"^(?P<kind>[A-Z]{3})-(?P<year>\d{4})-(?P<serial>\d+)$")
print(extracted)
str.extract with named groups is the function that has no spreadsheet counterpart and replaces the
largest amount of formula. A reference format that would take three nested FIND and MID calls to
decompose becomes one pattern that either matches or produces NaN — and the NaN tells you which rows
do not follow the format, which the formula version never would.
Splitting an email or an address into parts
A practical composite: pull the domain out of an email address, and flag the rows where the address does not look like one.
records["Domain"] = records["Contact"].str.split("@").str[-1]
records["Valid_Email"] = records["Contact"].str.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", na=False)
print(records.loc[~records["Valid_Email"], ["Reference", "Contact"]])
.str.split("@").str[-1] chains two string accessors — split produces a column of lists, and the
second .str indexes into each list. That chaining is the idiom to reach for whenever a value needs
decomposing and then selecting, and it stays vectorised throughout.
Numbers that have to become text
Report labels frequently mix numbers into strings, and that is where the translation of Excel's TEXT
function is least direct. Excel's TEXT(A2, "#,##0.00") applies a number format and returns a
string; pandas has formatting built into Python rather than into a function, which is more flexible
and requires saying more.
amounts = pd.Series([1200.5, 98000.0, 42.125])
# =TEXT(A2, "#,##0.00")
formatted = amounts.map(lambda v: f"{v:,.2f}")
# =TEXT(A2, "0.0%")
rates = pd.Series([0.0725, 0.128, 0.4])
percentages = rates.map(lambda v: f"{v:.1%}")
# =TEXT(A2, "0000") — zero-padded identifiers
codes = pd.Series([12, 407, 3])
padded = codes.astype("string").str.zfill(4)
print(formatted.tolist(), percentages.tolist(), padded.tolist())
str.zfill is the one worth knowing because it solves the leading-zero problem that Excel creates
constantly: an account code stored as a number loses its zeros, and padding it back is a single call
once you know the intended width.
A caution that applies to all three: producing a formatted string means the value is no longer a number, so it cannot be summed, sorted numerically or charted. When the destination is a spreadsheet, the better answer is nearly always to write the raw number and set the cell's number format, which keeps both the value and its appearance — the approach in Format Excel Cells as Currency with Python.
Cleaning a column before it becomes a key
Text functions are most often used in service of a join, and the failure mode is subtle: two columns that look identical on screen but differ in an invisible character. Non-breaking spaces from a web export, zero-width characters from a copy-paste, and trailing tabs all survive a visual inspection.
import unicodedata
def normalise_key(series: pd.Series) -> pd.Series:
return (
series.astype("string")
.map(lambda v: unicodedata.normalize("NFKC", v) if pd.notna(v) else v)
.str.replace(r"[ \t]", " ", regex=True)
.str.replace(r"\s+", " ", regex=True)
.str.strip()
.str.casefold()
)
records["Key"] = normalise_key(records["Customer"])
NFKC normalisation folds the compatibility characters that cause most of these problems — a
full-width letter, a ligature, a non-breaking space — into their ordinary equivalents. Applying the
same function to both sides of a join is what makes it reliable; applying it to one side makes the
mismatch worse.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
AttributeError: Can only use .str accessor with string values | The column is numeric or object-typed | .astype("string") first |
| Concatenation produces NaN for some rows | + propagates missing values | Use str.cat(..., na_rep="") |
Numbers become 1200.0 when concatenated | Float formatting during conversion | Cast to Int64 before astype("string") |
str.replace does nothing | The pattern is treated as a regex and contains special characters | Pass regex=False for literal replacements |
| A join finds no matches after cleaning | Only one side was normalised | Apply the same cleaning to both sides |
str.contains raises on missing values | NaN in the column | Pass na=False |
Performance and scale
String operations are the slowest column operations in pandas, because strings are Python objects
rather than packed numbers. Three choices make a large difference. Prefer regex=False when the
pattern is a literal — the regex engine is a real cost. Chain methods rather than calling apply
with a lambda, which pays a Python call per row. And convert a column with few distinct values to
category before joining or grouping on it.
# Slower: a Python call per row
records["Doc_Type"] = records["Reference"].apply(lambda ref: ref[:3])
# Faster: vectorised slicing
records["Doc_Type"] = records["Reference"].str[:3]
Where a text column drives repeated grouping — a region, a status, a document type — the categorical conversion pays for itself immediately, and it also shrinks the memory the frame occupies, which matters on the large exports covered in Working with Large Excel Files in Python.
Conclusion
LEFT, RIGHT and MID are all .str[a:b]; CONCATENATE is + and TEXTJOIN is str.cat with a
placeholder; TRIM needs both strip and a whitespace collapse to match Excel exactly. Beyond the
direct translations, str.split(expand=True) is Text to Columns in a line and str.extract with
named groups replaces whole stacks of nested FIND and MID formulas — while telling you which rows do
not fit the pattern.
Frequently asked questions
Why is .str0:3 used instead of a LEFT function? Because a pandas string column supports Python slicing directly, and slicing expresses LEFT, RIGHT and MID with one syntax instead of three functions. .str:3 is LEFT, .str-3: is RIGHT, and .str2:5 is MID with a start and length.
What is the difference between + and str.cat for joining columns? Plus produces NaN if any part is missing, exactly as CONCATENATE would produce an error. str.cat with na_rep supplies a placeholder instead, which is usually what a report needs.
How do I split a column into several, like Text to Columns? str.split with expand=True returns a DataFrame of the parts, which you can assign to several columns at once. Pass n to limit the number of splits when the last part may itself contain the separator.
Do these methods work on a numeric column? No — .str requires a string dtype. Convert first with .astype('string'), and be aware that a float column converts to values like '1200.0' rather than '1200', so cast to a nullable integer type before converting if that matters.
Related
- Up one level: Excel Formula Equivalents in pandas — the wider function map.
- Strip Whitespace and Normalise Text Columns with Pandas — the cleaning pass these functions belong to.
- Convert Excel Text Columns to Numbers with Pandas — turning extracted text back into numbers.
- Split One Excel Sheet into Multiple Files by Value — using an extracted key to partition a workbook.
- Validate Excel Data with Pandera Schemas — asserting the patterns these extractions rely on.