Skip to content

pandas str.startswith

Ask whether every string in a column begins with something, and get back a mask you can select rows with.

What it does

What do you do when the thing that identifies a row lives at the front of the string? Codes are built that way on purpose. ICAO airport identifiers, World Bank indicator codes, account numbers, file paths: the first character or two says what kind of thing this is, and the rest says which one.

str.startswith asks exactly that question of every element — does this string begin with that text? — 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.

str.contains can answer the same question with an anchored pattern, '^K', and people reach for it out of habit. Do not. startswith is faster, it takes several prefixes at once, and it never treats your prefix as a regular expression. Use str.contains when the value is a sentence and what you want is buried inside it; use str.startswith when the value is a code and you know where the answer lives.

str.endswith is the mirror image, with identical arguments.

Official documentation: Series.str.startswith and Series.str.endswith

The arguments that earn their keep

There are two, and one of them is the prefix:

s.str.startswith('K')            # one prefix
s.str.startswith(('EG', 'EI'))   # several — a tuple, not a list
s.str.startswith('L', na=True)   # what a missing value counts as

pat is a literal string, or a tuple of them if you want several. A list raises TypeError: expected a string or tuple, not list, which is the most helpful error in this whole family of methods — nothing is guessed and nothing is silently wrong.

na decides what a missing value means, and it is the same analytical decision covered at length on the str.contains page.

There is no case= argument. s.str.startswith('lake', case=False) raises TypeError, so lowercase the column first — which is what Bamboo Weekly #175: Inflation does to drop the euro-area aggregate rows: ~pd.col('Reference area').str.lower().str.startswith('euro').

A worked example, on real data

OurAirports publishes every airfield on the planet as a CSV, with the ICAO identifier in the first column:

import pandas as pd

url = 'https://davidmegginson.github.io/ourairports-data/airports.csv'

df = pd.read_csv(url)

df[['ident', 'type', 'name', 'iso_country']].head()
  ident           type                  name iso_country
0   00A       heliport     Total RF Heliport          US
1  00AA  small_airport  Aero B Ranch Airport          US
2  00AK  small_airport          Lowell Field          US
3  00AL  small_airport          Epps Airpark          US
4  00AN  small_airport  Katmai Lodge Airport          US

85,939 rows. ICAO codes are allocated by region, and every code in the continental United States begins with K. One call gives me the mask:

df['ident'].str.startswith('K').sum()
5537

When I want more than one region, I pass a tuple. EG is the United Kingdom and EI is Ireland:

df.loc[df['ident'].str.startswith(('EG', 'EI')), 'iso_country'].value_counts()
iso_country
GB    225
EG     83
IE     43
AQ      3
GG      2
JE      1
IM      1
FK      1
Name: count, dtype: int64

225 British and 43 Irish airfields, as expected — and 83 in Egypt, which I did not expect at all. OurAirports gives placeholder identifiers like EG-0001 to airfields that have no ICAO code, and Egypt's ISO country code happens to be EG. A two-character prefix is a two-character prefix; the method did what I asked. This is the reason to look at the result rather than trust it.

Three mistakes people make

Writing str.contains('^K') instead. It gives the same answer here — I checked, the two masks are identical on all 85,939 rows — and it costs you:

startswith('K')                 0.38 ms
contains('^K')                  1.05 ms
contains('^K', regex=False)     0.63 ms   ← matches 0 rows

Look at the third line. Somebody who has been bitten by the regexp default before adds regex=False to be careful, and the ^ becomes a literal caret. No error, no warning, zero rows.

Letting a regexp metacharacter into the prefix. This is the real argument for startswith, and Bamboo Weekly has two examples of prefixes that would be disasters as patterns — 'SE.' in #81: School and '$' in #182: Surveillance technology:

codes = pd.Series(['$1,200 grant', 'SE.PRM.ENRR', 'USD 400', 'SEXPD.TOTL'])

codes.str.startswith('$').tolist()   # [True, False, False, False]
codes.str.contains('$').tolist()     # [True, True, True, True]

$ means "end of string" to a regexp engine, so every row matches. And contains('SE.') picks up SEXPD.TOTL, because the dot matches the X. startswith has no such failure mode: the prefix is always literal.

Forgetting what a missing value counts as. 76,885 of these rows have no IATA code. In Pandas 3 a str column gives back a plain bool series with those rows False, which is usually what you want — until you negate:

(~df['iata_code'].str.startswith('L')).sum()          # 85481
(~df['iata_code'].str.startswith('L', na=True)).sum() # 8596

Nearly ten times the difference, from one keyword argument. Choose on purpose.

Where it shows up in Bamboo Weekly

Eight Bamboo Weekly solutions use str.startswith, and exactly one uses str.endswith. Four worth studying:

Bamboo Weekly #62: Economic report card is the plainest version, and free to read. IMF data reports each indicator in several units, and only the percentages are wanted, so the chain filters with df_['Units'].str.startswith('Percent') before doing any arithmetic.

Bamboo Weekly #81: School is the anchored-code case. World Bank education indicators all carry codes beginning SE., and one .loc[lambda df_: df_['Indicator Code'].str.startswith('SE.')] cuts the whole indicator catalogue down to the education rows.

Bamboo Weekly #182: Surveillance technology splits police-department summaries into words and keeps the ones starting with '$' to pull dollar amounts out of prose. Median spend: $40,760.

Bamboo Weekly #184: Parmesan cheese uses the negated form for the chore everyone eventually needs — dropping the Unnamed columns a spreadsheet leaves behind, with ~pd.col('index').str.startswith('Unnamed').

Practice it

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

Go deeper

str.startswith produces a mask, so its partner is .loc, and the usual next step is to take the prefix back off with str.removeprefix — not .str.strip(), which will eat more than you meant. When the answer is not at the front of the string, str.contains is what you want; when the value is a whole category rather than the prefix of one, isin says so more directly and runs faster than either.

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 8 Bamboo Weekly exercises that use str.startswith on real-world data — try each one, then study the worked solution.

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