Changing the case of a text column — and why that is almost never what you are really doing.
What they do
When did you last need a column in lowercase? Not for display; nobody wants a report that whispers. You needed it because two values describing the same thing were spelled with different capital letters, and Pandas, quite correctly, told you they were different.
That is what these methods are for. Case folding is hardly ever the goal. It is what you do so that things which ought to be equal actually compare as equal — in ==, in value_counts, in groupby, in merge. Each method is one call and takes no arguments, so the difficulty is never the API.
.str.lower()puts every character in lowercase.str.upper()puts every character in uppercase.str.title()capitalizes the first letter of each word, lowercasing the rest.str.capitalize()capitalizes the first letter of the whole value, lowercasing the rest.str.casefold()is.str.lower(), but more aggressive about non-English text
Official documentation: Series.str.lower, Series.str.upper, Series.str.title, Series.str.capitalize and Series.str.casefold
The first two do nearly all of the work. Which one you pick does not matter, so long as both sides of the comparison get the same treatment.
.str.casefold() exists because some languages need more than lowercasing before two spellings match. German is the standard case: ß and ss are the same letter to a reader, and .str.lower() will not tell you so.
pd.Series(['STRASSE', 'Straße']).str.lower().nunique()
2
pd.Series(['STRASSE', 'Straße']).str.casefold().nunique()
1
On plain ASCII the two are identical — across the 33,189 distinct business names below they disagree on exactly zero. If your data might not be English, casefold costs nothing and buys correctness.
A worked example, on real data
Chicago publishes every food-safety inspection its inspectors have carried out, with the name the business trades under typed in by hand. I will freeze the file at the end of 2024 so the numbers here stay put:
import pandas as pd
url = ('https://data.cityofchicago.org/resource/4ijn-s7e5.csv'
'?$select=dba_name,facility_type,results,zip'
"&$where=inspection_date<'2025-01-01'"
'&$limit=400000')
df = pd.read_csv(url)
df['dba_name'].value_counts().head(6)
dba_name
SUBWAY 3711
DUNKIN DONUTS 1993
MCDONALD'S 787
7-ELEVEN 552
CHIPOTLE MEXICAN GRILL 442
MCDONALDS 406
Name: count, dtype: int64
285,144 inspections, and McDonald's appears twice in the top six — 787 and 406 — so neither number is the answer to "how many times was McDonald's inspected?" Ask the column for everything beginning with mcdonald, in any case:
(df['dba_name']
.loc[lambda s_: s_.str.lower().str.startswith('mcdonald')]
.value_counts()
.head(6))
dba_name
MCDONALD'S 787
MCDONALDS 406
MCDONALD'S RESTAURANT 130
McDONALD'S 122
MCDONALDS RESTAURANT 87
MCDONALD'S RESTAURANTS 65
Name: count, dtype: int64
Some of that spread is case — MCDONALD'S against McDONALD'S — and some of it is the apostrophe. Case folding fixes precisely one of the two:
df['dba_name'].str.lower().value_counts().head(6)
dba_name
subway 3925
dunkin donuts 2091
mcdonald's 956
7-eleven 635
chipotle mexican grill 592
mcdonalds 520
Name: count, dtype: int64
mcdonald's rises from 787 to 956, which is the three case variants merged. mcdonalds sits at 520 and is still a different row, because lowercasing does nothing about a missing apostrophe. Across the whole column: 33,189 distinct names before folding, 32,821 after. Case alone accounts for 368 of them.
Counts are one thing; an answer that changes is another. What fraction of inspections end in failure, by kind of establishment? The facility_type column holds "coffee shop" in three capitalizations:
(df
.loc[lambda df_: df_['facility_type'].str.lower() == 'coffee shop']
.assign(failed=lambda df_: df_['results'] == 'Fail')
.groupby('facility_type')['failed']
.agg(['size', 'mean'])
.round(3))
size mean
facility_type
COFFEE SHOP 12 0.083
Coffee shop 15 0.133
coffee shop 27 0.370
Three rows, three failure rates — 8%, 13% and 37% — for one kind of business. Sort that table by mean to find Chicago's riskiest establishments and one imaginary category lands near the top while another lands near the bottom. Fold the grouping key and the real answer appears:
(df
.loc[lambda df_: df_['facility_type'].str.lower() == 'coffee shop']
.assign(failed=lambda df_: df_['results'] == 'Fail',
facility_type=lambda df_: df_['facility_type'].str.lower())
.groupby('facility_type')['failed']
.agg(['size', 'mean'])
.round(3))
size mean
facility_type
coffee shop 54 0.241
54 inspections, 24% of them failures — and 24% is none of the three numbers you saw a moment ago.
The option that leaves the column alone
If all you want is to find rows, you do not have to fold anything. str.contains takes a case argument, and setting it to False makes the match case-insensitive without producing a new column:
df['dba_name'].str.contains('mcdonald').sum()
1
df['dba_name'].str.contains('mcdonald', case=False).sum()
2505
One row against 2,505. (The one is a business that really did register itself as mcdonalds, in lowercase.) case=False is the right tool when you are filtering; .str.lower() is the right tool when you are grouping, joining or counting, because those need the values themselves to match.
Common mistakes
Reaching for .str.title() to tidy up names. It is the friendliest-looking of the five and the one that will embarrass you:
(df['dba_name']
.loc[lambda s_: s_.isin(["MCDONALD'S", '7-ELEVEN', 'KFC',
'JJ FISH & CHICKEN', 'IHOP'])]
.drop_duplicates()
.to_frame()
.assign(title=lambda df_: df_['dba_name'].str.title(),
capitalize=lambda df_: df_['dba_name'].str.capitalize())
.reset_index(drop=True))
dba_name title capitalize
0 MCDONALD'S Mcdonald'S Mcdonald's
1 7-ELEVEN 7-Eleven 7-eleven
2 KFC Kfc Kfc
3 JJ FISH & CHICKEN Jj Fish & Chicken Jj fish & chicken
4 IHOP Ihop Ihop
Mcdonald'S. Python treats the apostrophe as a word break and capitalizes the letter after it, and it has no idea that Mc is a prefix or that KFC and IHOP are acronyms. In this one column, 4,493 of the 33,189 distinct names come out of .str.title() carrying a capital S after an apostrophe, and 131 more begin with Mc. .str.capitalize() gets the apostrophe right and then lowercases everything else, which is worse. Only 7-ELEVEN survives intact. Reach for .str.title() when the values are ordinary English words — never for names, brands or codes.
Folding the key and then displaying the folded key. Lowercase the column in place and your counts become correct while your report starts mumbling: subway, dunkin donuts, mcdonald's. Fold a copy for matching and keep the original for display:
(df
.assign(chain=lambda df_: df_['dba_name'].str.lower())
.value_counts(['chain', 'dba_name'])
.reset_index()
.assign(inspections=lambda df_: df_.groupby('chain')['count'].transform('sum'))
.drop_duplicates('chain')
.nlargest(6, 'inspections')
.set_index('dba_name')['inspections'])
dba_name
SUBWAY 3925
DUNKIN DONUTS 2091
MCDONALD'S 956
7-ELEVEN 635
CHIPOTLE MEXICAN GRILL 592
MCDONALDS 520
Name: inspections, dtype: int64
Grouped on the folded name, labeled with the spelling that occurred most often. The same discipline applies to a merge: add a folded key to both frames, join on that, and display whichever original you prefer.
Blaming case for a mismatch that is really something else. MCDONALDS and MCDONALD'S above are the object lesson — no amount of folding will bring them together, because the difference is punctuation. Trailing whitespace behaves the same way, and it is invisible in printed output. Before deciding that case is your problem, run the cheap checks in str.strip and friends.
Calling these on a column that is not text. The .str accessor checks the dtype and refuses, which is the best outcome available:
df['zip'].str.lower()
AttributeError: Can only use .str accessor with string values, not floating
An object column holding a mixture is the quiet version. There the accessor works and drops anything that is not a string:
pd.Series(["MCDONALD'S", 60614, None, 'Subway']).str.lower()
0 mcdonald's
1 NaN
2 None
3 subway
dtype: object
The ZIP code became NaN, indistinguishable from the value that was already missing. Genuine missing values in a real str column pass through untouched, which is the behavior you want; a stray integer masquerading as one is not.
Where it shows up in Bamboo Weekly
Fifteen Bamboo Weekly solutions change the case of a column: .str.lower() in ten, .str.title() in four, .str.upper() in exactly one, and .str.capitalize() and .str.casefold() in none at all.
Bamboo Weekly #33: Fracking is the purest version of the problem. A StateName column typed in by hand produced 75 distinct values, among them Texas, tx, Ok, MS, Texasa and Pennsylvanya. .str.title() and .str.strip() knocked out the capitalization and whitespace variants; the misspellings then needed replace and a dictionary, because no string method will ever guess that Texasa is Texas.
Bamboo Weekly #32: Unions uses .str.upper() as a detector instead. The Bureau of Labor Statistics table puts top-level industry categories in ALL CAPS and sub-categories in mixed case, so df['Industry'] == df['Industry'].str.upper() is a boolean mask that selects the summary rows. Case as a signal, rather than noise to be scrubbed away.
Bamboo Weekly #79: Cyber attacks is .str.lower() in its most common role: the last step before a count. The chain splits every incident description into words, strips the punctuation, drops the short ones, lowercases what remains — "so that we won't see abc and ABC as different" — and only then calls value_counts.
Bamboo Weekly #121: Research funding uses .str.title() deliberately, inside an assign, to make NIH institution names readable before a groupby — and shows the cost right in the published output, where a top grant recipient is listed as Harvard School Of Public Health. That capital O is .str.title() doing exactly what it promises.
Practice it
Work through a .str.lower() exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/str-lower/
Go deeper
Case is the second thing to suspect when two values will not match, not the first. str.strip covers the invisible whitespace ahead of it, and str.replace the punctuation that folding cannot touch. Once the key is clean, the methods that pay you back are value_counts, groupby and drop_duplicates — all three compare values exactly, and all three quietly give you the wrong answer if you skip this step. When the differences turn out not to be mechanical at all, replace with a dictionary is where the cleanup ends.
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.
Related methods
.str.contains()— which takes case=False, so you can match without changing the column at all.str.strip()— when the values differ by whitespace or punctuation rather than by case.drop_duplicates()— when inconsistent case is splitting what should be one value.value_counts()— when inconsistent case is splitting one category into several counts
See it on real data
Below are the 10 Bamboo Weekly exercises that use str.lower on real-world data — try each one, then study the worked solution.
- Bamboo Weekly #182: Surveillance technology
- Bamboo Weekly #180: Movies
- Bamboo Weekly #175: Inflation
- Bamboo Weekly #159: State of the Union
- Bamboo Weekly #144: Museum Heists
- Bamboo Weekly #137: UN Security Council
- Bamboo Weekly #129: Tom Lehrer
- Bamboo Weekly #106: Flu season
- Bamboo Weekly #79: Cyber attacks
- Bamboo Weekly #48: Aviation accidents
Part of the Pandas Methods Index. See also practice by skill.