The .str accessor - vectorised string work without a loop, and what it does with missing values.
Overview
Why the accessor exists
Text data in pandas is stored as a column of Python string objects. Each cell is an independent object sitting somewhere in memory, connected to the column by a pointer. That storage model is why string columns are slow and memory-heavy compared to numeric columns.
You could process strings with a Python loop or with apply. Both work. Both are slow, because they call Python once per cell. The str accessor provides vectorised string methods that push the loop into compiled code where possible, and that return a properly aligned Series with NaN for missing entries.
Beyond speed, the accessor gives you a readable, chainable syntax. Cleaning a name column might take three calls: strip whitespace, convert to lowercase, replace internal spaces with underscores. With the accessor, that is one chained expression. Without it, that is three separate apply calls or a lambda that does all three steps and is harder to read.
The accessor also handles missing values consistently. Every str method returns NaN for a missing input rather than raising or returning an empty string. That means you can chain string operations on a column with gaps and get NaN in the result where the input was NaN. No special handling required.
String cleaning is a large part of real-world data work. Names arrive in mixed case, with extra whitespace, with inconsistent separators. Codes arrive with trailing spaces that make exact matches fail. Categories have multiple spellings. The str accessor is how you fix all of these at column scale.
This module covers the most-used methods: strip, lower, upper, contains, replace, extract, split and len. It also covers when to convert a text column to the category dtype for better performance on repeated values.
A Series of strings is a column of Python string objects. You could loop over them. apply(str.strip) would work. Both are slow and neither reads well.
.str exposes the string methods so they apply to the whole column: s.str.strip(), s.str.lower(), s.str.len().
Each returns a Series, so they chain: s.str.strip().str.lower().str.replace(" ", "_").
The accessor is needed. s.strip() isn't the same thing. It either does a thing unrelated to the Series or fails. strip isn't a Series method. Losing .str is the most common beginner error here. The resulting AttributeError at least says so clearly.
Worth knowing
.str applies a string method to every value and returns a Series, so the calls chain.
Missing values pass through as NaN rather than raising — and a mask containing NaN cannot be used to index, so pass na=False.
contains takes a regex by default; pass regex=False for a literal. match anchors at the start, fullmatch at both ends.
split(expand=True) makes columns; extract with named capture groups makes one column per group.
str.replace needs an explicit regex= in pandas 2 — the old default changed.
.str is convenient, not fast: object columns are per-element Python, and text work is far slower than arithmetic.
Text Columns
The .str accessor, and what it does with missing values.
The .str accessor applies to every value
The same methods you know from Python, one call for the whole column.
example_01.pypandas
import pandas as pd
s = pd.Series([" Ana ", "RAJ", "kim "])
print("raw :", list(s))
print("strip :", list(s.str.strip()))
print("lower :", list(s.str.lower()))
print("title :", list(s.str.strip().str.title()))
print("len :", list(s.str.len()))
print()
print("they chain, because each returns a Series:")
print(" ", list(s.str.strip().str.lower().str.replace("a", "@")))
print()
print("without .str you would be calling a method on the Series itself,")
print("which does something else entirely or fails.")
Output
Missing values pass through
.str returns NaN rather than raising, which is usually what you want and occasionally hides a problem.
example_02.pypandas
import pandas as pd
import numpy as np
s = pd.Series(["ana", None, "kim", np.nan])
print("upper :", list(s.str.upper()))
print("len :", list(s.str.len()))
print(" the gaps stayed gaps - no error, no empty string")
print()
print("that matters when you filter on the result:")
print(" contains('a') :", list(s.str.contains("a")))
print(" the NaN rows are NaN, not False - and NaN is not a valid mask")
print()
try:
s[s.str.contains("a")]
except ValueError as e:
print(" using it directly ->", type(e).__name__, ":", str(e)[:44])
print()
print("na=False is the fix:")
print(" ", list(s[s.str.contains("a", na=False)]))
Output
.str methods return NaN for missing input rather than raising or returning an empty string.
That is often right: the uppercase of an unknown value is unknown.
It turns a problem for predicates. s.str.holds("a") returns True, False **or NaN**. A mask holding NaN can't be used to index a frame. Pandas raises "Can't mask with non-boolean array holding NA / NaN values".
The fix is na=False. This Decides what a missing value should count as:
df[df["name"].str.contains("ana", na=False)]
Pass it each time you filter on a text predicate. na=True is at times what you want, when missing should be included.
This is the single most common runtime error in text cleaning. It appears only when the data has a gap. So it often shows up in production rather than on the sample.
Testing and finding
contains, startswith and match, and which of them takes a regex.
example_03.pypandas
import pandas as pd
s = pd.Series(["order-1001", "invoice-22", "order-3", "note"])
print("contains('order') :", list(s.str.contains("order")))
print("startswith('order'):", list(s.str.startswith("order")))
print()
print("contains takes a REGEX by default:")
print(" contains(r'\\d{4}') :", list(s.str.contains(r"\d{4}")))
print(" regex=False for a literal:", list(s.str.contains(".", regex=False)))
print()
print("match anchors at the start, fullmatch at both ends:")
print(" match('order') :", list(s.str.match("order")))
print(" fullmatch('note') :", list(s.str.fullmatch("note")))
Output
Splitting and extracting
split with expand, and extract for the parts a regex names.
example_04.pypandas
import pandas as pd
s = pd.Series(["order-1001", "invoice-22", "order-3"])
print("split into columns:")
print(s.str.split("-", expand=True))
print()
print("take one piece:")
print(" ", list(s.str.split("-").str[0]))
print()
print("extract uses capture groups and gives one column each:")
out = s.str.extract(r"(?P<kind>[a-z]+)-(?P<num>\d+)")
print(out)
print(" dtypes:", dict(out.dtypes.astype(str)))
print(" note num is object - extract returns text, convert if you need numbers")
Output
s.str.split("-") gives a Series of lists. That is rarely what you want right.
expand=True turns it into a DataFrame with one column per piece. This Is how you split a mixed field into real columns. Rows with fewer pieces get None in the trailing columns.
s.str.split("-").str[0] takes one piece — the second .str indexes into the lists.
extract is often better when the structure is known. It takes a regex with capture groups and returns one column per group. Named groups become column names:
s.str.extract(r"(?P<kind>[a-z]+)-(?P<num>\d+)")
Rows that don't match give NaN across the row. This Makes non-matching input visible rather than silently mangled.
extract returns text, even for digits. Convert later with pd.to_numeric if you need numbers.
extractall returns each match rather than the first, with a MultiIndex.
Replacing, with and without regex
The default changed in pandas 2, so be explicit.
example_05.pypandas
import pandas as pd
s = pd.Series(["a.b.c", "x.y", "no dots"])
print("literal replace:")
print(" ", list(s.str.replace(".", "-", regex=False)))
print()
print("regex replace:")
print(" ", list(s.str.replace(r"\.", "-", regex=True)))
print()
print("a common clean-up - collapse runs of whitespace:")
messy = pd.Series(["too many spaces", "fine"])
print(" ", list(messy.str.replace(r"\s+", " ", regex=True)))
print()
print("and tidy up column names in one line:")
df = pd.DataFrame(columns=[" First Name ", "AGE (years)"])
df.columns = (df.columns.str.strip().str.lower()
.str.replace(r"[^a-z0-9]+", "_", regex=True).str.strip("_"))
print(" ", list(df.columns))
Output
It is convenient, not fast
Under the hood this is still per-element Python.
example_06.pypandas
import pandas as pd
import time
n = 200_000
s = pd.Series(["value-%d" % i for i in range(n)])
t = time.perf_counter(); a = s.str.len(); str_time = time.perf_counter() - t
t = time.perf_counter(); b = s.str.startswith("value"); sw = time.perf_counter() - t
num = pd.Series(range(n))
t = time.perf_counter(); c = num * 2; num_time = time.perf_counter() - t
print("str.len on %d strings : %.4f s" % (n, str_time))
print("str.startswith : %.4f s" % sw)
print("numeric multiply : %.4f s" % num_time)
print("text is %.0fx slower than arithmetic here" % (str_time / max(num_time, 1e-9)))
print()
print("Object columns store pointers to Python strings, so every")
print("operation walks them one at a time. Convert to category when")
print("the values repeat, and do text work once rather than in a loop.")
Output
Testing
holds searches anywhere in the string. startswith and endswith anchor. match anchors at the start, fullmatch at both ends.
**holds takes a regular formula by default.** That matters more than it sounds. It means s.str.holds(".") matches each non-empty string rather than finding a literal dot. s.str.holds("a|b") is an alternation rather than a search for the three characters.
Pass regex=False for a literal search. It's also faster.
case=False makes the test case-insensitive without a split .lower() pass.
startswith and endswith do not take a regex. This Is an inconsistency worth remembering rather than deriving.
Replacing
s.str.replace(old, new, regex=...) needs the regex arg to be clear in pandas 2. The default changed. Code written against pandas 1 that leaned on the old action can silently do the wrong thing.
Two clean-ups earn their place in almost each script:
That turns " First Name " and "AGE (years)" into first_name and age_years. It handles a whole spreadsheet's worth of inconsistent headers without listing them.
Note df.columns.str works too — the column index is an Index. Index has a .str accessor as well.
The cost
.str is a convenience, not a vectorisation.
Object columns hold pointers to Python strings. Each step walks them one at a time in Python. The last editor shows text work running far slower than math on the same number of rows.
Three things help.
Do it once. Clean text at load time rather than again and again inside a loop or a function called per group.
**Convert to group** when values repeat. steps then run on the small set of distinct values rather than each row.
**Consider the string dtype.** astype("string") gives the nullable extension type. This Has clearer missing-value semantics than object and is where pandas' future tuning work is going.
For very large text columns, the honest answer is that pandas isn't the right tool. The work belongs in a DB or a purpose-built library.
The cleaning pipeline
Text from the real world needs the same handful of steps almost each time. Doing them in one pass at load time is far better than scattering them through the analysis:
Strip the ends, norm case, collapse inner whitespace. That alone resolves most of the "same value counted twice" problems that show up in a group-by.
For text that will be checked or joined on, consider also dropping punctuation and accents. unicodedata.norm handles accents. Pandas has no built-in for it. .str.norm("NFKD") plus an ASCII encode is the usual idiom.
Extracting structure
str.extract with named groups is the most handy of the extraction methods. It names the outputs:
Building an alternation is fine for a handful of words. For a long list. For whole-word matching, it's worth escaping the parts with re.escape and adding word boundaries, or the results will surprise you.
Splitting into columns
str.split(sep, expand=True) gives a DataFrame. Rows with fewer parts get None in the trailing columns. Rows with more parts are truncated unless you pass n to limit the splits.
n=1 splits only on the first separator. This Is what you want for key. Value text where the value may hold the separator.
str.rsplit splits from the right. This Handles "all before the last dot" cleanly.
str.split returns just three columns. Before, separator, after — which avoids the var-width problem fully.
Categorical text
Once text is cleaned, if it repeats, convert it:
df["city"] = df["city"].astype("category")
The .str accessor still works on a cat column. Pandas applies the step to the groups rather than to each row. So df["city"].str.upper() on a million rows with four cities does four steps, not a million.
That is the single largest speed-up on hand for repeated text work, and it's a one-line change.
When to stop using pandas for text
pandas is a fair place to clean and extract from moderate amounts of text. It isn't a text-handling engine.
For token split, stemming, language spotting or anything linguistic, a dedicated library is the right tool.
For very large corpora, the object dtype's per-row Python cost dominates. The work belongs in a DB, in Polars, or in a purpose-built pipeline.
The signal is often the profile: if .str steps are the slowest part of your script and the frame is large, the answer is a other tool rather than a cleverer regex.
Regex, briefly
a few .str methods take a regular formula. A small vocab covers most data cleaning:
\d digit, \w word character, \s whitespace, . any character.
+ one or more, * zero or more, ? optional.
^ start, $ end, word boundary.
[abc] a character set, [^abc] its negation.
(...) a capture group, (?P<name>...) a named one.
| alternation.
Two habits prevent most regex trouble in pandas. Use raw strings. r"\d+". So backslashes reach the regex engine intact. And test the pattern on a handful of values before applying it to the column. A pattern that matches nothing makes a column of NaN rather than an error.
str.holds(..., regex=False) and str.replace(..., regex=False) are both faster and safer when the pattern is a literal.
The fix is at read_csv, with encoding="latin-1" or encoding="cp1252", not in pandas later. Repairing mojibake after the fact is possible and unreliable.
df["col"].str.encode("utf-8").str.decode("utf-8") round-trips text and raises on anything invalid. This Is a way to find the offending rows.
For matching across accented and unaccented spellings, norming with str.norm("NFKD") and stripping mixing characters puts both forms into the same shape.
Performance, restated
Three things make text work faster, in order of effect:
**Convert to group** when values repeat. steps then apply to the groups, not the rows.
Do the cleaning once, at load time, rather than inside a function called per group or per row.
**Use regex=False** where the pattern is a literal.
And the structural point: if .str steps dominate the profile on a large frame, pandas is the wrong layer for that work. A DB, Polars, or a purpose-built text pipeline will do it in a fraction of the time.
A summary
.str applies string methods elementwise and chains.
Missing values pass through as NaN. Pass na=False on any predicate used for filtering.
holds is a regex by default. startswith isn't a regex at all.
split(expand=True) makes columns. extract with named groups is often clearer.
replace needs an clear regex= in pandas 2.
Norm column names and text values once, at load.
Convert repeated text to group.
And check how many rows a pattern really matched, rather than given it matched them all.
A closing note on Text Columns
Text is where pandas is least like an array library and most like plain Python, and both facts show.
.str gives you the string methods across a whole column. This Is convenient and reads well. Underneath it's per-element Python over a column of pointers. This Is why text work is an order of magnitude slower than math on the same number of rows.
Two things follow. Clean text once, at load, rather than again and again in the middle of a pipeline. And convert repeated values to group, after which .str steps apply to the handful of distinct values rather than to each row. Often the largest single speed-up on hand, for a one-line change.
The correctness trap is missing values. .str predicates return NaN rather than False. A mask holding NaN can't index a frame. na=False on each .str filter is the habit. Its lack is the error that appears only once the data has a gap.
Questions people ask about Text Columns
How do I apply string functions to a whole column? Use the .str accessor, for example df['name'].str.lower() or df['name'].str.strip(). It works element by element and skips missing values.
Why do I get an AttributeError on .str? The column is not a string dtype, often because it holds numbers or mixed types. Convert with astype(str) or fix the source column.
How do I split one column into several? Use df['full'].str.split(' ', expand=True), which returns a frame with one column per piece. Assign the result to new column names.
How do I filter rows that contain some text? Use df[df['col'].str.contains('abc', case=False, na=False)]. The na=False part keeps missing values from breaking the mask.
Does str.contains use regular expressions? Yes, by default. Pass regex=False for a plain substring, especially when your pattern contains characters like ( or .
Are .str methods fast? They are convenient but still loop over the values in Python. For big columns, convert to category first or use vectorised alternatives where they exist.
One more thing
str.get(i) indexes into each string or list. This Is the concise form of str[i] and works the same way. It returns NaN rather than raising where the index is out of range. This Is often what you want on ragged data.
Check yourself
0 of 4
Answer without scrolling back up.
Why does `df[df['name'].str.contains('a')]` sometimes raise?
Pass na=False every time you filter on a text predicate. The error appears only when the data has a gap, so it surfaces in production.
What does `s.str.contains('.')` match?
Pass regex=False for a literal search - it is also faster. Note startswith and endswith do not take a regex at all.
What does `str.extract` return for a row that does not match?
That makes non-matching input visible rather than silently mangled. Note extract returns text even for digits - convert with pd.to_numeric.
Why is `.str` slower than arithmetic on the same number of rows?
Clean text once at load time, and convert to category when values repeat so operations run on the distinct values instead.
Cheat sheet
Text Columns
Use the .str accessor to split, replace and test text columns, and learn how each method handles missing values.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.