Cleaning & Transforming Data
Real datasets arrive with wrong dtypes, missing values, and messy strings. Cleaning makes aggregates trustworthy before you merge or model.
Recipe
Quick-reference recipe card - copy-paste ready.
import pandas as pd
df = pd.read_csv("raw.csv", dtype={"region": "category"})
df["revenue"] = pd.to_numeric(df["revenue"], errors="coerce")
df["sku"] = df["sku"].str.strip().str.upper()
df = df.dropna(subset=["revenue"])When to reach for this:
- First pass on any CSV, API export, or spreadsheet ingest
- Before joins (keys must be normalized)
- Before train/test splits in ML pipelines
- When aggregates look impossible (strings summing as zero)
Working Example
import pandas as pd
import numpy as np
from io import StringIO
raw = """order_id,region,revenue,sku,ordered_at
1, east , $120 , abc-1 , 01/15/2025
2,WEST,340,def-2,2025-01-16
3,EAST,N/A,abc-1,2025-01-17
"""
df = pd.read_csv(StringIO(raw), dtype={"region": "string[pyarrow]"})
# Strip and normalize text keys
df["region"] = df["region"].str.strip().str.title()
df["sku"] = df["sku"].str.upper()
# Parse money and dates
df["revenue"] = (
df["revenue"]
.str.replace("$", "", regex=False)
.str.replace(",", "", regex=False)
.replace("N/A", np.nan)
)
df["revenue"] = pd.to_numeric(df["revenue"], errors="coerce")
df["ordered_at"] = pd.to_datetime(df["ordered_at"], format="mixed", utc=True)
# Impute with documented policy: median by region
df["revenue"] = df.groupby("region", observed=True)["revenue"].transform(
lambda s: s.fillna(s.median())
)
# Drop rows still missing critical keys
clean = df.dropna(subset=["order_id", "ordered_at"]).astype({"order_id": "int64"})
print(clean.dtypes)
print(clean)What this demonstrates:
- Arrow-backed strings for memory-efficient text ops
straccessor chains for whitespace and casingto_numeric(errors="coerce")turning bad tokens into NA- Group-wise imputation instead of a global mean
Deep Dive
How It Works
- Missing values map to
NaNfor floats,pd.NAfor nullable dtypes, orNonein object columns. - Vectorized string methods run per element without Python loops.
astypemay copy;convert_dtypes()upgrades to nullable pandas extension types.- Categorical dtype stores levels once - great for repeated region names.
Common Transforms
| Task | API |
|---|---|
| Parse numbers | pd.to_numeric(..., errors="coerce") |
| Parse dates | pd.to_datetime(..., utc=True) |
| Clip outliers | s.clip(lower, upper) |
| Rename columns | df.rename(columns={"old": "new"}) |
| Dedupe rows | df.drop_duplicates(subset=[...]) |
Python Notes
import pandas as pd
# Prefer nullable Int64 when NA is possible
df["units"] = pd.array([1, None, 3], dtype="Int64")
# map for small domain replacements
df["status"] = df["status"].map({"A": "active", "I": "inactive"})Gotchas
- Global fillna(0) on missing revenue - treats "unknown" as zero sale. Fix: separate NA policy per column; use group medians or flags.
- In-place mutation on a view -
df.dropna(inplace=True)on a slice corrupts parent. Fix: assign back:df = df.dropna(). - Regex special chars in str.replace -
$and.needregex=Falseor escaping. Fix: passregex=Falsefor literal replacements. - Locale date ambiguity -
01/02/2025parses differently by locale. Fix: enforce ISO"%Y-%m-%d"at source or useformat="mixed"with audit. - Category typos -
"East"and"east "become different categories before strip. Fix: normalize strings beforeastype("category").
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Pandera | Schema validation in CI | Quick notebook scrub |
| Great Expectations | Production data contracts | One-off CSV cleanup |
Polars with_columns | Large lazy pipelines | Team only knows pandas |
| OpenRefine / SQL | Human-in-loop cleanup at scale | Scripted repeatable ETL |
FAQs
Should I drop or impute missing values?
- Drop when missingness is rare and random.
- Impute when dropping would bias groups - document the statistic used.
How do I find duplicate keys before a merge?
dupes = df[df.duplicated("order_id", keep=False)]What does errors="coerce" do?
- Invalid parses become
NaNinstead of raising. - Follow with
dropnaor a quarantine table for bad rows.
How do I standardize column names?
df.columns = df.columns.str.strip().str.lower().str.replace(" ", "_")When should I use category dtype?
- Low-cardinality strings used in groupby/plots.
- Avoid when levels change constantly (high cardinality).
How do I split one column into many?
df[["city", "state"]] = df["location"].str.split(",", expand=True)Can I clean in chunks for huge CSVs?
for chunk in pd.read_csv("huge.csv", chunksize=100_000):
process(chunk)- Write cleaned chunks to Parquet partitions.
How do I detect outliers?
- IQR rule:
Q3 + 1.5*IQR- flag, do not silently drop without review. - Use domain caps (revenue cannot be negative).
What is pd.NA vs np.nan?
pd.NAis pandas scalar missing for nullable extension dtypes.np.nanis float missing - propagates in float columns.
How do I validate cleaning in tests?
- Assert row counts, key uniqueness, and
df["revenue"].isna().sum() == 0after policy. - Snapshot dtypes as a dict in pytest.
Related
- pandas Series & DataFrames - selection basics
- Joins & Merges - clean keys before joining
- Data Validation & Quality - Pandera and Great Expectations
- Performance & Memory - categoricals and Arrow strings
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.