Skip to content

Text and String Methods (str accessor and regex)

Why not just use Python string methods?

Python’s built-in string methods (.lower().lower(), .split().split(), .replace().replace()) work great on a single string. But a DataFrame column can hold thousands of strings — and, almost always, a few missing values mixed in. Calling .lower().lower() directly on every value with .map().map() works fine until it hits a NaNNaN, and then it crashes.

Pandas solves this with the strstr accessor: series.str.lower()series.str.lower() runs a vectorized, element-wise version of the string method across the whole column, and automatically skips (propagates) missing values instead of erroring out.

Example dataset

Messy text column
import pandas as pd
 
emails = pd.Series(
    ["Dave@Google.com", "steve@gmail.com ", None, "  rob@gmail.com"],
    index=["Dave", "Steve", "Wes", "Rob"],
)
 
print(emails)
print(emails.isna())
Messy text column
import pandas as pd
 
emails = pd.Series(
    ["Dave@Google.com", "steve@gmail.com ", None, "  rob@gmail.com"],
    index=["Dave", "Steve", "Wes", "Rob"],
)
 
print(emails)
print(emails.isna())

Everyday cleanup: lower, strip, contains, replace

lower / strip / title
print(emails.str.lower())
print(emails.str.strip().str.lower())
lower / strip / title
print(emails.str.lower())
print(emails.str.strip().str.lower())

str.containsstr.contains checks each value for a substring (or pattern) and returns a boolean Series — pass na=Falsena=False so missing values count as “no match” instead of showing up as NaNNaN in a mask:

str.contains with na=False
has_gmail = emails.str.contains("gmail", case=False, na=False)
print(has_gmail)
print(emails[has_gmail])
str.contains with na=False
has_gmail = emails.str.contains("gmail", case=False, na=False)
print(has_gmail)
print(emails[has_gmail])

str.replacestr.replace swaps out a substring everywhere it appears — pass regex=Falseregex=False when your search text isn’t a pattern, so characters like .. aren’t treated as “any character”:

str.replace (literal, not regex)
print(emails.str.replace("gmail", "GMAIL", regex=False))
str.replace (literal, not regex)
print(emails.str.replace("gmail", "GMAIL", regex=False))

Splitting and pulling pieces out

str.splitstr.split breaks each string into a list at a delimiter; indexing into the result with .str[i].str[i] or .str.get(i).str.get(i) pulls out one piece of each list:

str.split and str.get
parts = emails.str.strip().str.split("@")
print(parts)
print(parts.str.get(0))   # username, before the @
print(parts.str.get(1))   # domain, after the @
str.split and str.get
parts = emails.str.strip().str.split("@")
print(parts)
print(parts.str.get(0))   # username, before the @
print(parts.str.get(1))   # domain, after the @

Slicing works too, the same way you’d slice a plain Python string:

Slicing with the str accessor
print(emails.str.strip().str[:4])
Slicing with the str accessor
print(emails.str.strip().str[:4])

Regex with .str.extract.str.extract and .str.findall.str.findall

For real pattern matching — not just a fixed substring — pass a regular expression. str.extractstr.extract pulls out groups (the parts in parentheses) into their own columns; str.findallstr.findall returns every match as a list:

str.extract splits an email into groups
import re
 
pattern = r"([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+)\.([A-Za-z]{2,4})"
 
clean_emails = emails.str.strip()
print(clean_emails.str.extract(pattern))
str.extract splits an email into groups
import re
 
pattern = r"([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+)\.([A-Za-z]{2,4})"
 
clean_emails = emails.str.strip()
print(clean_emails.str.extract(pattern))

The result is a small DataFrame with one column per capture group — column 00 is the username, 11 is the domain, 22 is the suffix. Rows that don’t match (or were NaNNaN to begin with) come back as NaNNaN in every column, not an error.

str.findall returns every match
print(clean_emails.str.findall(pattern, flags=re.IGNORECASE))
str.findall returns every match
print(clean_emails.str.findall(pattern, flags=re.IGNORECASE))

Common pitfalls

  • Forgetting regex=Falseregex=False on str.replacestr.replace when your replacement text contains regex special characters like .. or (( — it will silently match more than you intended.
  • Boolean masks from str.containsstr.contains can contain NaNNaN (for missing input) unless you pass na=Falsena=False — filtering with a mask that still has NaNNaN in it raises an error.
  • str.extractstr.extract needs parentheses (...)(...) around the parts you want back; a pattern with no groups extracts nothing useful.

Visualize it

diagram The str accessor skips missing values mermaid
Every str method runs element-wise down a column, passing real values through the string/regex logic and letting NaN pass straight through untouched.

🧪 Try It Yourself

Exercise 1 – Lowercase an Entire Column

Exercise 2 – Check Membership While Ignoring NaN

Exercise 3 – Extract a Regex Group

Next

With text under control, move on to Reading and Writing Data (CSV, Excel, JSON) to load real datasets that will need exactly this kind of cleanup.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did