03 · Data Cleaning & Wrangling¶
Real data is never clean. Duplicate rows, inconsistent text casing, mixed date formats, and missing values show up in almost every dataset you'll ever load. This module builds a repeatable checklist for turning a raw export into something safe to analyze.
A realistically messy dataset¶
import numpy as np
import pandas as pd
raw = pd.DataFrame({
"customer_id": [101, 102, 103, 104, 104, 106],
"signup_date": ["2024-01-15", "2024/02/03", "2024-02-20", None, None, "2024-03-01"],
"plan": [" Pro", "basic", "PRO", "Basic", "Basic", "enterprise "],
"monthly_spend": [49.99, 9.99, 49.99, np.nan, np.nan, 199.0],
})
print(raw)
customer_id signup_date plan monthly_spend
0 101 2024-01-15 Pro 49.99
1 102 2024/02/03 basic 9.99
2 103 2024-02-20 PRO 49.99
3 104 NaN Basic NaN
4 104 NaN Basic NaN
5 106 2024-03-01 enterprise 199.00
This one small table already has four separate problems: duplicate rows
(customer 104 twice), inconsistent date formats, inconsistent text
capitalization/whitespace in plan, and missing values.
Step 1 — find what's wrong before fixing anything¶
customer_id 0
signup_date 2
plan 0
monthly_spend 2
dtype: int64
0 False
1 False
2 False
3 False
4 True
5 False
dtype: bool
Always run .isna().sum() and check for duplicates before touching
anything — deciding how to handle missing/duplicate data is a judgment call,
and you need to know the scope of the problem first.
Step 2 — remove duplicates¶
customer_id signup_date plan monthly_spend
0 101 2024-01-15 Pro 49.99
1 102 2024/02/03 basic 9.99
2 103 2024-02-20 PRO 49.99
3 104 NaN Basic NaN
5 106 2024-03-01 enterprise 199.00
keep="first" keeps the first occurrence of each customer_id. The .copy()
avoids a SettingWithCopyWarning later when you start assigning new columns
onto a filtered DataFrame.
Step 3 — normalize inconsistent text¶
Before this, " Pro", "PRO", and "pro" would have been treated as three
different categories by anything downstream (a groupby, a plot legend, a
model). .str.strip().str.lower() is a two-line fix that prevents a whole
class of silent bugs.
Step 4 — parse inconsistent dates¶
format="mixed" lets pandas infer the format per-value, which handles the
"2024-01-15" vs. "2024/02/03" inconsistency in one call. A value that
can't be parsed at all becomes NaT ("Not a Time") rather than crashing the
whole conversion.
Step 5 — handle missing values deliberately¶
median_spend = df["monthly_spend"].median()
print(median_spend) # 49.99
df["monthly_spend"] = df["monthly_spend"].fillna(median_spend)
print(df)
49.99
customer_id signup_date plan monthly_spend
0 101 2024-01-15 pro 49.99
1 102 2024-02-03 basic 9.99
2 103 2024-02-20 pro 49.99
3 104 NaT basic 49.99
5 106 2024-03-01 enterprise 199.00
There is no universally "correct" way to fill missing data — the median is a reasonable, outlier-resistant default for a skewed numeric column like spend, but always document what you filled and why in your analysis. The alternatives worth knowing:
| Strategy | When to use it | Risk |
|---|---|---|
dropna() |
Missingness is rare and random | Loses real data; can bias results if missingness isn't random |
fillna(mean/median) |
Numeric column, need a complete dataset for modeling | Understates real variance |
fillna(mode) |
Categorical column | Can overrepresent the majority category |
fillna(method="ffill") |
Time series, value "carries forward" | Wrong if the true value actually changed |
Leave as NaN/NaT |
Aggregations (.mean(), .sum()) that already skip missing values by default |
None — often the safest choice |
Step 6 — verify the final dtypes¶
A cheap, high-value habit: check .dtypes after every cleaning pass. A date
column that's still a string, or a numeric column that got coerced to text
by one stray non-numeric value, causes bugs much further downstream that are
harder to trace back to their source.
Cheat sheet¶
| Task | Code |
|---|---|
| Count missing per column | df.isna().sum() |
| Find duplicate rows | df.duplicated(subset=["col"]) |
| Drop duplicates | df.drop_duplicates(subset=["col"], keep="first") |
| Normalize text | df["c"].str.strip().str.lower() |
| Parse mixed-format dates | pd.to_datetime(df["c"], format="mixed") |
| Fill missing numeric | df["c"].fillna(df["c"].median()) |
| Rename columns | df.rename(columns={"old": "new"}) |
| Check types after cleaning | df.dtypes |
How It Actually Works¶
How duplicated()/drop_duplicates() actually detect duplicates.
Pandas doesn't compare every row to every other row (an O(n²) operation that
would be unusable on large data). Instead, for the specified subset
columns it computes a hash of each row's values and groups rows by that
hash — an O(n) pass, the same hash-table strategy groupby uses internally.
Rows sharing a hash are then compared for exact equality (to guard against
rare hash collisions), and keep="first" marks every row after the first
occurrence within each equal-hash group as a duplicate. This is also why
duplicate detection is sensitive to exact value equality: "Basic" and
"basic" hash differently, which is precisely why Step 3 (text
normalization) needs to happen independently of duplicate removal, not as a
substitute for it.
Why format="mixed" can parse two different date strings in one column.
pd.to_datetime normally compiles one strptime-style format string and
applies it to every value for speed. format="mixed" instead falls back to
per-value format inference: for each string, pandas' C-level date parser
tries a cascade of common patterns (ISO YYYY-MM-DD, slash-separated
YYYY/MM/DD, and others) until one matches, then converts the matched
value into a Timestamp — a 64-bit integer count of nanoseconds since the
Unix epoch (1970-01-01), which is what lets datetime64 columns support
fast arithmetic and comparison later. A string matching none of the
cascade's patterns becomes NaT, pandas' NaN-equivalent for datetimes,
which propagates safely through comparisons and aggregations instead of
raising.
Why the median (not the mean) is the safer fill for skewed data. The
mean is pulled toward extreme values because every point enters the Σxᵢ/n
sum with equal weight; the median is the middle value after sorting, so it
is a function only of rank/position, not magnitude — a single outlier
10x too large moves the mean substantially but leaves the median completely
unchanged, which is exactly the property that makes it the safer default
for fillna() on real-world monetary columns that are rarely symmetric.
Exercise¶
Take the cleaned df above and add one more issue to fix: a stray outlier
row with monthly_spend = 999999 (a clear data-entry error, not a real
enterprise customer). Detect it using the IQR rule (Q1 - 1.5*IQR to
Q3 + 1.5*IQR, covered fully in Module 04), decide whether to drop it or cap
it, and justify your choice in one sentence.