Data Cleaning Checklist

Four phases: Inspect → Standardize → Repair → Verify. Three rules hold throughout:

  • The raw file is never edited. Cleaning lives in code so it can be re-run.
  • Profile before fixing. Every change should answer something you actually saw.
  • Count what each step dropped or changed; that becomes the audit at the end.

1. Inspect

Look, don’t touch.

Load and freeze

raw = pd.read_csv("data.csv")   # or read_parquet / read_sql
df = raw.copy()
n_raw = len(raw)

raw stays untouched as the baseline for every before/after comparison.

Profile the table

df.shape
df.head()
df.sample(5)      # head() hides problems further down

df.info()                     # dtypes + non-null counts
df.describe(include="all")    # numeric + categorical

# missing rate per column
df.isna().mean().sort_values(ascending=False)

df.corr(numeric_only=True)

Look for numbers read as object, nearly empty columns, and ranges that make no sense.

Profile each column

# " NYC", "nyc" and "N/A" show up as separate values
df["city"].value_counts(dropna=False)
df["city"].nunique()

df["price"].describe()

value_counts(dropna=False) on every categorical column is the fastest way to find formatting noise and disguised missing values.

2. Standardize

Make names, types and formats consistent so values become comparable.

Column names

df.columns = (
    df.columns.str.strip()
              .str.lower()
              .str.replace(r"[^\w]+", "_", regex=True)
)
df = df.rename(columns={"cust_id": "customer_id"})

Missing placeholders

NA_TOKENS = ["", " ", "NA", "N/A", "null", "-", "?", "unknown"]
df = df.replace(NA_TOKENS, np.nan)

# numeric sentinels
df["age"] = df["age"].replace({-1: np.nan, 999: np.nan})

Do this before converting types. Otherwise errors="coerce" turns the placeholders into NaN silently and you lose track of which ones were sentinels.

Types and formats

# text: whitespace and case
df["city"] = df["city"].str.strip().str.title()

# numeric: strip symbols, then coerce
df["price"] = df["price"].str.replace(r"[$,]", "", regex=True)
df["price"] = pd.to_numeric(df["price"], errors="coerce")

# dates
df["signup_date"] = pd.to_datetime(df["signup_date"], errors="coerce")

# codes -> labels
# unmapped values become NaN, so check value_counts first
df["gender"] = df["gender"].map({"M": "male", "F": "female"})

# how many values did coercion lose?
df["price"].isna().sum() - raw["price"].isna().sum()

Coercion never raises; it quietly produces NaN. Count the loss after each conversion.

Duplicates

df.duplicated().sum()                          # exact copies
df.duplicated(subset=["customer_id"]).sum()    # same key

# keep the latest row per key
df = (
    df.sort_values("updated_at")
      .drop_duplicates(subset=["customer_id"], keep="last")
)

This comes after standardization: "NYC" and " nyc " only become duplicates once formats agree. Decide explicitly which row survives, usually the latest.

3. Repair

Decide what to do with the values that are absent or extreme.

Missing values

# is missingness random, or tied to another column?
df.isna().mean()
df.groupby(df["income"].isna())["age"].mean()

# drop a column that is mostly empty
df = df.drop(columns=["fax"])

# drop rows missing a key field
df = df.dropna(subset=["customer_id", "signup_date"])

# keep a flag before filling: "was missing" is often informative
df["income_was_na"] = df["income"].isna()

# median for skewed numeric, constant for categorical
df["income"] = df["income"].fillna(df["income"].median())
df["city"] = df["city"].fillna("Unknown")

For modeling, compute fill values on the training split only; otherwise the test set leaks into the imputer.

Outliers

# IQR: robust to the outliers themselves
q1, q3 = df["price"].quantile([0.25, 0.75])
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
is_out = ~df["price"].between(lo, hi)

# z-score: assumes roughly normal data,
# and mean / std are themselves pulled by the outliers
z = (df["price"] - df["price"].mean()) / df["price"].std()
is_out = z.abs() > 3

# look before touching: entry error, unit mix-up, or real?
df.loc[is_out, ["customer_id", "price"]]

# then flag (safest), cap, or transform
df["price_outlier"] = is_out

cap = df["price"].quantile(0.99)
df["price"] = df["price"].clip(upper=cap)

df["log_price"] = np.log1p(df["price"])

Remove only confirmed errors. A real but extreme value is information, not noise.

4. Verify

Prove the result is valid and that cleaning did not distort it.

Validity checks

assert df["customer_id"].is_unique
assert df["age"].between(0, 120).all()
assert (df["end_date"] >= df["start_date"]).all()

total_ok = np.isclose(df["subtotal"] + df["tax"], df["total"])
assert total_ok.all()

Write the domain rules as assert so the pipeline fails loudly when new data breaks them.

Audit and save

dropped = 1 - len(df) / n_raw
print(f"rows {n_raw} -> {len(df)} ({dropped:.1%} dropped)")

df.isna().mean()      # should be ~0 where you filled

pd.DataFrame({
    "raw": raw["age"].describe(),
    "clean": df["age"].describe(),
})

df.to_parquet("clean_2026-10-12.parquet")

A large shift in mean or variance between raw and clean means a step was too aggressive. Finally, wrap phases 2 to 4 in one clean(raw) function so the same cleaning runs on the next batch.