Skip to content

pandas str.lower

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.

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.

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.

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