Python · Chapter 8 · Concept lesson · 7 min read

Cleaning means making equal things equal

pandas compares values exactly and treats NaN as a special 'unknown'. Cleaning is the work of making values that mean the same thing look the same, and deciding what missing really means.

The idea

pandas does not understand your data. It compares values exactly, character by character. 'Dallas', 'dallas' and 'Dallas ' (with a trailing space) are three different values to it, and 'Dallas' and 'Boston' are no more different than those three are from each other.

That is the whole problem. Harbor & Pine has 2,018 customers and the people who typed them in were human. Cities have spaces and capitals in odd places. Some customers appear twice. Nine products have no recorded cost. Two dozen order lines have a quantity of zero or less. None of it raises an error: groupby, mean and merge all run and give you a number. The number is wrong because the groups or the rows were wrong.

Cleaning is therefore one idea in three forms: make values that mean the same thing look the same (text), decide what a missing value means (NaN), and decide which rows are really one entity (duplicates).

Figure 1

Four spellings of Dallas become one group

customers.city.value_counts()
citycustomers
'Dallas'78
' dallas '3
'Dallas '1
'DALLAS'1
After .str.strip().str.title()
citycustomers
Dallas83

Step 1 of 4 Dallas is in the data four times over. To pandas these are four different strings, so groupby('city') makes four groups and the biggest has only 78 customers.

  1. Dallas is in the data four times over. To pandas these are four different strings, so groupby('city') makes four groups and the biggest has only 78 customers.
  2. .str.strip() removes the spaces at the ends. ' dallas ' becomes 'dallas' and 'Dallas ' becomes 'Dallas'.
  3. .str.title() fixes the capital letters, so 'dallas' and 'DALLAS' become 'Dallas'. Now every variant is the same string.
  4. One spelling, one group: 83 customers. The count was never wrong, the groups were.

Why it exists

Real data comes from forms, exports, imports from other systems and people on a Friday afternoon. Nobody designed ' dallas '. If you skip cleaning, the report still renders, and that is the danger: a top-cities chart that says Dallas has 78 customers looks perfectly reasonable. A colleague repeats it in a meeting, and a month later a real count says 83.

Cleaning also has a cost. Every step is a decision about what the data means, and a decision can be wrong. The habit that makes it safe is to clean in code, never by editing the file by hand, so the steps are written down and can be rerun on next month's export.

How it works under the hood

Text: normalise before you compare

The .str accessor applies a text operation to every value of a column at once. The usual recipe is strip() for the ends, lower(), upper() or title() for capitals, and replace() for known variants. Missing values pass through untouched: .str.strip() on a NaN gives NaN, not an error and not the string 'nan'. A .str method on a column that is not text, such as numbers stored as text with a $, needs the symbol removed and astype(float) or pd.to_numeric afterwards.

Normalise a copy of the column, use it to group or compare, and keep the original if you need to show the original. Test it the way a person would: df.city.nunique() before and after tells you how many spellings you merged.

Missing values: skipped or propagated

NaN means "unknown". isna() finds it, fillna(x) replaces it, dropna() removes the row. Most pandas summaries skip NaN by default, so mean() of a column with gaps averages only what is there, and sum() treats gaps as nothing. Arithmetic between columns works differently: a NaN in unit_cost makes unit_price - unit_cost NaN on that row, and the margin for that product disappears.

Figure 2

Average unit_cost of the 20 Furniture products, two ways

$0 $100 $200 $300 $400 Average unit cost mean() as it is: $308fillna(0).mean(): $231 mean() as it isfillna(0).mean() 15 known costs

Step 1 of 3 Five of the 20 Furniture products have no recorded unit_cost. mean() skips NaN and averages the 15 costs that exist: $308.11.

  1. Five of the 20 Furniture products have no recorded unit_cost. mean() skips NaN and averages the 15 costs that exist: $308.11.
  2. fillna(0) turns the five unknowns into zero-dollar costs and divides by 20. The answer drops to $231.08, and every margin built on it looks better than it is.
  3. Neither number is automatically right. Skipping is honest when the missing costs look like the known ones. Filling with 0 claims that five sofas and beds cost nothing.

So there are three honest options, and each makes a different claim. Leave it as NaN and the summary describes the rows that have data. dropna() does the same but makes it explicit. fillna() with a value claims you know what the number would have been. Use fillna(0) only when zero is truly what missing means, for example a missing discount_pct on an order line that had no discount. Everything else is a judgement, and the report should say which one you made.

Types: dates and numbers stored as text

A column of dates read as text sorts alphabetically and cannot be grouped by month. pd.to_datetime turns it into real timestamps, and then .dt.year, .dt.month and .dt.day_name() work. Add errors='coerce' to turn unparseable entries into NaT instead of stopping, then count the NaT values: they are the rows you still have to explain. pd.to_numeric(..., errors='coerce') does the same for numbers.

Duplicates: same entity, different spelling

df.duplicated() marks a row True when an earlier row has identical values in the columns you name. Identical is the key word, so duplicates written differently go through, unless you normalise first.

Figure 3

The same person signed up twice

customers
customer_idemailsignup_date
1698bjones76@gmail.com2025-10-12
2014BJONES76@GMAIL.COM2025-12-31
2015jack.nguyen@icloud.com2025-12-31
email.str.strip().str.lower()
customer_idemail_key
1698bjones76@gmail.com
2014bjones76@gmail.com
2015jack.nguyen@icloud.com

Step 1 of 4 Customer 2014 looks new, with a signup in December. But its email is the same address as customer 1698, typed in capitals.

  1. Customer 2014 looks new, with a signup in December. But its email is the same address as customer 1698, typed in capitals.
  2. duplicated('email') compares the raw text. 'bjones76@gmail.com' and 'BJONES76@GMAIL.COM' are different strings, so pandas finds no duplicate here.
  3. Normalise first, then compare. After strip() and lower() both rows carry the same key. Across the table this finds 18 repeated emails, against 6 for the raw text.
  4. Keep the first signup (keep='first') and customer 2014 is gone. Without that, 'new customers' is overcounted.

Choose the key that defines "the same" with care. Same email is a good key for customers. Same first name and city is not: two different people can share a first name and a city. Then decide what to keep with drop_duplicates(subset=..., keep='first'), and look at a few removed rows before you trust it.

Bad rows

A quantity of 0 or -2 is not a typo to fix, it is a row that does not describe a sale. Filter it out with a boolean mask, and count what you removed: (order_items.quantity <= 0).sum() is 24 rows. A cleaning step that silently removes rows is a source of mismatched totals, so a short note of what went and why belongs next to the result.

Common misconceptions

"pandas will treat Dallas and DALLAS as the same city"

It will not. String comparison is exact, in groupby, merge, isin, duplicated and ==. The fix is to normalise the text before the operation. When a count looks too small, run nunique() and value_counts() on the column and look at the tail: the variants are usually sitting there.

"fillna(0) is the safe way to get rid of NaN"

It is the way that hides the problem best. Zero is a number, and it takes part in every mean, sum and ratio that follows. A product with a missing cost does not cost nothing. Fill only with a value you can defend, otherwise leave the NaN visible and say that those rows are not included.

"drop_duplicates() removes the duplicate customers"

Only exact copies of the whole row. Two signups of the same person have different customer_id and signup_date, so drop_duplicates() with no arguments removes nothing. Name the columns that define the entity (subset=) after normalising them, and keep the earliest signup.

Check yourself

One question. Answering it ticks this lesson off on the map; it never locks a level.

customers.city has 'Dallas' 78 times, ' dallas ' 3 times, 'Dallas ' once and 'DALLAS' once. What does customers['city'].value_counts().idxmax() give you, and is the Dallas count right?

Pick what may run on this site. You can change this any time from "Cookie settings" at the bottom of every page.