Skip to content

pandas str.contains

Ask every string in a column one yes-or-no question, and get back a mask you can select rows with.

What it does

How do you find the rows whose text mentions the thing you care about? Numeric columns make this easy — df['mag'] > 6 and you are done. Text columns do not. What you want is buried inside a sentence, next to nine other things, spelled three different ways.

str.contains does exactly one job: for every element in a series, it asks "does this string contain that pattern?" and returns True or False. The result is a boolean series the same length as the original, so it goes straight into .loc as a row selector. Build a mask, then select with it — that two-step is the most common thing I do with text in Pandas.

Two details about the matching drive everything else on this page. First, it is a search, not a match: the pattern can appear anywhere in the string, so 'Rule' finds 'Proposed Rule'. Second, the pattern is a regular expression by default. Not a plain substring — a regexp. That default causes more confusion than any other argument in the string accessor, and I will come back to it twice.

Official documentation: Series.str.contains

The arguments that earn their keep

s.str.contains('Airworthiness')              # regexp search, case-sensitive
s.str.contains('airworthiness', case=False)  # ignore case
s.str.contains('U.S.', regex=False)          # a literal string, dots and all
s.str.contains('tariff', na=False)           # decide what a missing value means
s.str.contains(r'^(?:ACTOR|ACTRESS)')        # anchors and alternation
s.str.contains('rule', flags=re.IGNORECASE)  # any other re module flag

pat is the pattern. The other four are all keyword arguments in practice — pass them by name, because nobody reading s.str.contains('rule', False) will guess that the False is case.

case is the one you reach for most. Leave it alone and the search is case-sensitive, which real data punishes constantly.

na decides what a missing value means. It behaves differently in Pandas 3 from everything you have read online, and it gets its own mistake below.

regex decides whether pat is compiled as a regular expression or matched literally, and it defaults to True. If your search term contains a ., a (, a ?, a +, a |, a [ or a $, that default is not what you want.

flags takes anything from the re module. One warning: it is silently ignored when you also pass regex=False — no error, just a case-sensitive literal search and a count that is quietly wrong.

This is where most Pandas users meet regular expressions for the first time, and it is worth half an hour of your time to stop guessing. I teach a free regular expressions crash course covering everything str.contains can use.

A worked example, on real data

The Federal Register publishes every rule, proposed rule and notice issued by the US government, and its API hands back CSV directly. Here is the first full week of 2026:

import pandas as pd

url = ('https://www.federalregister.gov/api/v1/documents.csv'
       '?per_page=1000'
       '&fields[]=title&fields[]=abstract&fields[]=agency_names'
       '&fields[]=type&fields[]=publication_date'
       '&conditions[publication_date][gte]=2026-01-02'
       '&conditions[publication_date][lte]=2026-01-09')

df = pd.read_csv(url)
df.shape
(433, 5)

433 documents in five working days. The title column is where the subject lives:

df['title'].head(4)
0    Amendments to Adjusting Imports of Timber, Lum...
1    Airworthiness Directives; The Boeing Company A...
2                                Sunshine Act Meetings
3    Fisheries of the South Atlantic, Gulf of Ameri...
Name: title, dtype: str

Say I want the airworthiness directives — the orders that tell airlines to go fix something on an airplane. One call, and I have a mask:

df['title'].str.contains('Airworthiness').head(4)
0    False
1     True
2    False
3    False
Name: title, dtype: bool

That is the whole idea: a boolean series, one value per row, True where the text matched. Sum it to count, or feed it to .loc to select:

df['title'].str.contains('Airworthiness').sum()
17
df.loc[pd.col('title').str.contains('Airworthiness'), 'agency_names'].value_counts()
agency_names
Transportation Department; Federal Aviation Administration    17
Name: count, dtype: int64

Seventeen orders to fix airplanes in one week, all from the same agency. Notice what .loc is doing: the mask picks the rows, the string picks the column, and the two arguments are independent.

The agency_names column is itself a good str.contains target, because it holds several agencies joined with semicolons rather than one value per row:

df['agency_names'].str.contains('Federal Aviation Administration').sum()
21

Twenty-one, not seventeen — the FAA published four more documents that week. And when the thing you want goes by more than one name, the regexp default finally becomes an asset rather than a hazard:

df['title'].str.contains(r'Airworthiness|Airspace|Airport').sum()
19

Alternation with | is the most useful regexp feature in this method: .str.contains(r'art|language|history') replaces three chained or conditions with one readable pattern.

contains, match, or fullmatch?

Three methods, one difference: where the pattern is allowed to sit. str.contains searches anywhere, str.match anchors at the start of the string, and str.fullmatch requires the pattern to consume the whole string. On this data frame's type column, which holds Notice, Rule, Proposed Rule and Presidential Document:

df['type'].str.contains('Rule').sum()     # 75
df['type'].str.match('Rule').sum()        # 49
df['type'].str.fullmatch('Rule').sum()    # 49

contains swept in all 26 proposed rules along with the 49 final ones. If you catch yourself wrapping a whole pattern in ^ and $, you wanted fullmatch.

Four mistakes people make

Assuming missing values give you NaN. This has changed, and most advice online is out of date. In Pandas 3 a text column read from a CSV has the new str dtype, and str.contains on it returns a plain bool series: missing values come back False, and the mask works in .loc without any help. 127 of these 433 abstracts are missing, and df['abstract'].str.contains('antidumping', case=False) still returns dtype: bool.

The trap survives on object-dtype columns — what you get from older Pandas, from pickles and parquet files written years ago, and from any column built by hand out of mixed types:

old = pd.read_csv(url, dtype=object)

old['abstract'].str.contains('antidumping', case=False).head(3)
0      NaN
1    False
2      NaN
Name: abstract, dtype: object

An object series of three-valued logic, which .loc refuses outright:

ValueError: Cannot mask with non-boolean array containing NA / NaN values

na=False fixes it. But do not treat na= as boilerplate you sprinkle on to make an error go away — it is a real analytical decision, and it bites hardest when you negate. Asking for the documents that never mention antidumping:

(~df['abstract'].str.contains('antidumping', case=False)).sum()          # 415
(~df['abstract'].str.contains('antidumping', case=False, na=True)).sum() # 288

A 127-row difference, from one keyword argument. na=False says a document with no abstract does not mention antidumping; na=True says it does. Neither is obviously right, so choose on purpose.

Forgetting that the pattern is a regular expression. U.S. is the perfect trap, because it looks like a plain string and is not. To a regexp engine, . means "any character," so U.S. matches a U, then anything, then an S, then anything. Here is the damage on the abstracts, searching case-insensitively:

df['abstract'].str.contains('U.S.', case=False, na=False).sum()               # 203
df['abstract'].str.contains('U.S.', case=False, regex=False, na=False).sum()  # 90

203 rows, of which 90 actually mention the United States. The other 113 were dragged in by ordinary English. These are the words that did it, counted across those rows:

request       30
pursuant      18
unsafe        16
substances    12
requested      9
substance      9

Every one of those holds a u, any character, an s. If you want a literal string, say so with regex=False, or escape the pattern with re.escape — both give 90 here. Bamboo Weekly #6: End of the humanities? hits the same wall without any punctuation at all: searching majors for 'culture' returned "Agriculture and natural resources," and the fix was r'\bculture'. Regexps are not the enemy here. Unexamined regexps are.

Searching with the wrong case, on data that has a house style. Government data is written in Title Case, and it is consistent about it:

df['agency_names'].str.contains('department').sum()  # 0
df['agency_names'].str.contains('Department').sum()  # 272

Zero out of 433, when 272 of these documents came from a Department. Nothing raised, nothing warned; the answer was just wrong. Whenever the pattern is a word rather than a code, pass case=False unless you have a reason not to — and where the case is meaningful, lock it in deliberately, the way Bamboo Weekly #51: Academy Awards does with r'^(?:ACTOR|ACTRESS)' against a column of all-caps categories.

Using str.contains where == or isin was meant. This mistake costs you correctness and speed at once. df['type'].str.contains('Rule') returns 75 rows; df['type'] == 'Rule' returns 49. If you wanted exact category values, contains hands you a superset and never mentions it. For more than one category, isin says so directly: df['type'].isin(['Rule', 'Proposed Rule']). On this column repeated to 433,000 rows:

str.contains('Rule')                   40 ms
str.contains('Rule', regex=False)      23 ms
== 'Rule'                              12 ms
isin(['Rule', 'Proposed Rule'])         3.5 ms

isin is more than ten times faster than the regexp search, and it is the one that says what you meant. My rule: str.contains when the value is a sentence and you are looking inside it, == or isin when the value is a category and you know its name.

Where it shows up in Bamboo Weekly

30 Bamboo Weekly solutions use str.contains on Pandas data — it is how these puzzles usually get from a wall of text to the rows worth looking at. Four worth studying, all free to read:

Bamboo Weekly #73: Avocado hand walks into two of the four mistakes above and climbs back out. Searching CPSC emergency-room narratives for 'avocado' raises on the missing values, so the chain gains a .dropna() — the Pandas 2 way of doing what na=False does now. Then it returns zero rows, because hospital staff type those narratives in capitals, so the search gains case=False. The answer, once both are fixed: women are three times as likely as men to turn up with an avocado injury.

Bamboo Weekly #6: End of the humanities? is the best regexp walkthrough on the site: it builds 'art|language|history|culture', discovers that "culture" matched "Agriculture," and fixes it with \b, explaining along the way why the raw string matters.

Bamboo Weekly #3: Earthquakes is str.contains at its simplest: df['place'].str.contains('Turkey'), then .loc with that mask, to pull every quake in the February 2023 sequence out of a worldwide feed. Note the dropna immediately before it.

Bamboo Weekly #71: Holidays shows the negated form. Hunting for genuinely new national holidays turns up a pile of substituted days off and one-off royal occasions, so the chain ends with five .loc[lambda df_: ~df_['holiday'].str.contains(...)] steps in a row — 'Day off', ';', 'Elizabeth', 'Charles', 'Funeral' — each one carving away a category of noise.

Practice it

Work through a str.contains exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/str-contains/

Go deeper

str.contains answers a yes-or-no question, so its natural partner is .loc, which turns the answer into rows. When the answer is "yes, and I want the part that matched," you have outgrown it: str.split takes the string apart on a delimiter, and str.replace rewrites the matched piece in place. If you are only fighting inconsistent capitalization, str.lower once beats case=False on every line. And when the column holds categories rather than sentences, isin and value_counts are what you actually wanted.

Above all, learn the regexps — everything hard about this method is really something hard about patterns, and my free regular expressions crash course is a short way in.

More Pandas videos on Python and Pandas with Reuven Lerner.

Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.

See it on real data

Below are the 32 Bamboo Weekly exercises that use str.contains on real-world data — try each one, then study the worked solution.

Part of the Pandas Methods Index. See also practice by skill.