Handbooks / Pandas & Data Analysis / Chapter 2
Cleaning Data
7 pages · ~4 min✓ Reviewed
Builds on DataFrames and Indexing. Next up: GroupBy and Aggregation.
Data Cleaning Mental Model
1. What Cleaning Means
- Every column has the right dtype
- No hidden NaN, no duplicate rows
- Consistent labels and text, done before any analysis
2. How It Works
- Most cleaning methods return a new object
- The original stays untouched, so assign the result
python
df.drop_duplicates() # lost df = df.drop_duplicates() # kept
no assignment, no cleaning
- Inspectinfo, describe, isna().sum()
- Fix dtypes
- Handle missing
- Clean text, names
- Drop duplicates
df.info()shows dtype and non-null count per column- A text dtype (
strin pandas 3,objectbefore) on a numeric-looking column = dirty data
3. Vectorized vs Row-by-Row
| Style | Runs in | Speed |
|---|---|---|
df['a'] * 2, s.str.lower() | compiled code, whole column | fast |
df.apply(f, axis=1) | Python, once per row | slowest |
Remember
Row-by-row Python is the slow exception. Reach for a vectorized operation first.
Missing Values: isna, dropna, fillna
1. Detecting Missing
NaN,NoneandNaTall count as missingisna()gives a boolean mask;notna()is its inversedf.isna().sum()counts missing per columndf.isna().mean()gives the fraction missing
2. dropna()
| Argument | Effect |
|---|---|
axis=0 | drops rows |
how='any' | default: one NaN drops the row |
how='all' | drops only all-NaN rows |
subset=[cols] | checks only those columns |
thresh=n | keeps rows with n or more non-null |
3. fillna()
fillna(value)takes a scalar or a per-column dict- Standard numeric fill is the median
ffill()/bfill()fill from neighbours
python
df['a'] = df['a'].fillna(df['a'].median()) df = df.fillna({'a': 0, 'b': 'n/a'}) df['a'] = df['a'].ffill()
4. Mistakes
df[df.a == np.nan]matches nothing: NaN == NaN is False, so useisna()df['a'].fillna(x, inplace=True)never changes df under pandas 3 Copy-on-Write
Assign back
Write df['a'] = df['a'].fillna(x). The result is a new object.
Dtypes, Conversion and Duplicates
1. Reading dtypes
df.dtypeslists the type of every column- Mixed values in a column → object
- Int column with NaN → turns float
2. Converting types
| Call | On bad value |
|---|---|
astype('int64') | raises |
astype('datetime64[ns]') | raises |
astype('category') | never raises, every value a category |
astype('Int64') | nullable int, allows NaN (capital I) |
to_numeric / to_datetime | default errors='raise' |
same, errors='coerce' | NaN / NaT |
- Use coerce to turn unparseable values into NaN/NaT, then inspect them
python
df['age'] = pd.to_numeric(df['age'], errors='coerce') df['joined'] = pd.to_datetime(df['joined'], errors='coerce')
assign the result back
3. Duplicates
duplicated()marks repeat rows; keep='first' by default, so the first copy is Falsedrop_duplicates(subset=[cols], keep='first'|'last'|False)removes themkeep=Falsedrops every copy
python
df.duplicated().sum() df = df.drop_duplicates(subset=['email'], keep='last')
count first, then drop
Near-duplicates survive
With no subset, all columns are compared, so 'Ann' and ' ann ' stay. Clean text first, then dedupe.
String Methods and Renaming
1. The .str Accessor
.strapplies string methods to a whole Seriesstrip()lower()upper()title()len()replace('old','new', regex=False)= literal;regex=True= patternscontains('x', na=False)= boolean masksplit(',', expand=True)= separate columns
python
s.str.strip().str.title() s.str.replace(r'\s+', ' ', regex=True) df[s.str.contains('x', na=False)] s.str.split(',', expand=True)
2. Renaming
| Form | Renames | Result |
|---|---|---|
rename(columns={'old':'new'}) | chosen columns | copy |
df.columns = [...] | all; count must match | in place |
rename(columns=str.lower) | every name | copy |
3. Mistakes
.stron a non-string column raises AttributeError- Missing values return NaN, no error
Fix
Convert first: astype('string')
4. Interview Point
- Does rename change df? No, it returns a copy; assign it back.
- Why does .str fail? The dtype is not string; use
astype('string').
Apply vs Vectorized Operations
1. Speed Ladder
| Pattern | Python calls | Speed |
|---|---|---|
df['a'] * 2, s.str.lower(), np.where | none, compiled column-wide | fastest |
map(dict) | fast lookup | fast |
Series.apply(func) | once per element | slow |
df.apply(func, axis=1) | once per row | slowest |
2. Choosing and Replacing
- apply only when no vectorized form exists, e.g. multi-step custom logic
map(dict)recodes values by lookup- Replace row-wise if/else with
np.whereor a boolean mask
python
df['tier'] = np.where(df.score >= 50, 'pass', 'fail') df['city'] = df['code'].map({'H': 'Hyd', 'B': 'Blr'})
vectorized if/else and dict recode
3. Mistakes
Slow apply
df.apply(f, axis=1) on a large frame is often 10 to 100 times slower than the vectorized form.
- Default
axis=0passes whole columns, not rows - Why is axis=1 slowest? One Python call per row
- What replaces row-wise if/else?
np.whereor a mask
Pandas Cleaning Cheat Sheet
1. Missing and Types
- isna().sum()count
- dropna(subset=, thresh=)drop
- fillna(value | median)fill
- ffill()neighbour
info()/dtypes: a text dtype on a numeric-looking column means dirty data
| Call | Bad value | |
|---|---|---|
| Strict | astype('Int64'|'int64') | raises |
| Lenient | to_numeric / to_datetime(errors='coerce') | NaN / NaT |
2. Duplicates and Text
duplicated().sum()counts repeat rowsdrop_duplicates(subset=[...], keep='first'|'last'|False); False drops every copy.str.strip().str.lower()first, then dedupe.str.replace(..., regex=False)is a literal replacerename(columns={...})orcolumns=str.lower
3. Speed Order
| Order | Tool | Use for |
|---|---|---|
| 1 | Vectorized ops, np.where | whole columns |
| 2 | map(dict) | lookups |
| 3 | apply(axis=1) | last resort |
python
df['tier'] = np.where(df.score >= 50, 'pass', 'fail')
row-wise if/else, vectorized
Assign it back
Cleaning methods return a new object: df['a'] = df['a'].fillna(x), never inplace=True on a slice.
Check yourself
df['age'].fillna(0, inplace=True) runs without error in pandas 3. Does df change?
- No: Copy-on-Write makes
df['age']a temporary copy. - Assign back:
df['age'] = df['age'].fillna(0).
What does this return?
1.0, NaN, 3.0, dtype float64.'x'becomes NaN instead of raising; NaN forces float.
s = pd.Series(['1', 'x', '3']) pd.to_numeric(s, errors='coerce')
Rows 'Ann' and 'ann ' (trailing space): does drop_duplicates() merge them?
- No: it compares exact values, so both survive.
- Run
.str.strip().str.lower()first, then dedupe.