Data Cleaning Workflows¶
A one-off cleaning script is a liability — you run it once, forget what it does, and can't reproduce the result. A cleaning workflow is a documented, reusable pipeline that takes raw data in and delivers clean data out, with every decision logged.
Learning Objectives¶
- Structure a data cleaning pipeline as a series of named functions
- Use method chaining for readable Pandas workflows
- Document cleaning decisions as code comments and audit logs
- Validate cleaned data with assertions
- Build a reusable cleaning module
The Three-Stage Cleaning Pattern¶
Every cleaning workflow follows the same structure:
1. AUDIT — understand the raw data before touching it
2. CLEAN — apply transformations in a logical order
3. VALIDATE — verify the result matches expectations
Stage 1 — Audit¶
import pandas as pd
import numpy as np
def audit_dataframe(df, name="DataFrame"):
"""Print a comprehensive data quality report."""
print(f"\n{'='*60}")
print(f"AUDIT: {name}")
print(f"{'='*60}")
print(f"Shape: {df.shape[0]:,} rows × {df.shape[1]} columns")
print(f"\nData types:")
print(df.dtypes.to_string())
print(f"\nMissing values:")
missing = df.isnull().sum()
missing_pct = df.isnull().mean() * 100
missing_df = pd.DataFrame({"count": missing, "pct": missing_pct.round(1)})
print(missing_df[missing_df["count"] > 0].to_string())
print(f"\nDuplicate rows: {df.duplicated().sum():,}")
print(f"\nNumeric summary:")
print(df.describe().round(2).to_string())
Stage 2 — Clean (Pipeline Pattern)¶
Build cleaning as a series of functions that each take and return a DataFrame. Chain them with .pipe().
def fix_dtypes(df):
"""Convert columns to correct data types."""
df = df.copy()
df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")
df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
df["unit_price"] = (
df["unit_price"]
.astype(str)
.str.replace("£", "").str.replace(",", "")
.pipe(pd.to_numeric, errors="coerce")
)
return df
def standardise_strings(df):
"""Normalise all string columns."""
df = df.copy()
df["status"] = df["status"].str.lower().str.strip()
df["country"] = df["country"].str.title().str.strip()
df["product"] = df["product"].str.strip().str.replace(r"\s+", " ", regex=True)
return df
def handle_missing_values(df):
"""Apply business rules for missing data."""
df = df.copy()
df["discount_pct"] = df["discount_pct"].fillna(0)
df["delivery_date"] = df["delivery_date"] # keep as NaN — not yet delivered
df = df.dropna(subset=["order_id", "customer_id"]) # these are critical
return df
def remove_duplicates(df):
"""Remove duplicate orders, keeping the most recently updated."""
df = df.copy()
if "updated_at" in df.columns:
df = df.sort_values("updated_at", ascending=False)
df = df.drop_duplicates(subset=["order_id"], keep="first")
return df.reset_index(drop=True)
def add_calculated_columns(df):
"""Add derived columns used in all downstream analyses."""
df = df.copy()
df["total"] = df["quantity"] * df["unit_price"]
df["net_total"] = df["total"] * (1 - df["discount_pct"] / 100)
df["year"] = df["order_date"].dt.year
df["month"] = df["order_date"].dt.month
df["quarter"] = df["order_date"].dt.quarter
df["is_weekend"] = df["order_date"].dt.dayofweek >= 5
return df
def filter_valid_rows(df):
"""Remove rows that fail business logic validation."""
df = df.copy()
before = len(df)
df = df[df["quantity"] > 0] # no zero-quantity orders
df = df[df["unit_price"] > 0] # no zero-price orders
df = df[df["unit_price"] <= 10000] # price sanity check
after = len(df)
print(f"filter_valid_rows: removed {before - after} invalid rows")
return df
Chaining with .pipe()¶
def clean_orders(filepath):
"""Main cleaning pipeline — takes raw CSV path, returns clean DataFrame."""
return (
pd.read_csv(filepath)
.pipe(fix_dtypes)
.pipe(standardise_strings)
.pipe(handle_missing_values)
.pipe(remove_duplicates)
.pipe(add_calculated_columns)
.pipe(filter_valid_rows)
)
# Usage
df_raw = pd.read_csv("orders_raw.csv")
audit_dataframe(df_raw, "Raw Orders")
df_clean = clean_orders("orders_raw.csv")
audit_dataframe(df_clean, "Cleaned Orders")
Stage 3 — Validate¶
After cleaning, assert that the result meets expectations:
def validate_orders(df, name="orders"):
"""Assert data quality constraints are met."""
errors = []
if df["order_id"].duplicated().any():
errors.append("Duplicate order_ids found")
if df["order_id"].isnull().any():
errors.append("Null order_ids found")
if (df["quantity"] <= 0).any():
errors.append("Non-positive quantities found")
if (df["unit_price"] <= 0).any():
errors.append("Non-positive unit prices found")
if df["order_date"].isnull().any():
errors.append("Null order dates found")
if len(errors) == 0:
print(f"✓ {name}: all validations passed ({len(df):,} rows)")
else:
for error in errors:
print(f"✗ {name}: {error}")
raise ValueError(f"Data validation failed with {len(errors)} errors")
Audit Log — Document What You Did¶
import datetime
cleaning_log = []
def log_step(step_name, before_rows, after_rows, note=""):
"""Record each cleaning step for reproducibility."""
entry = {
"step": step_name,
"rows_before": before_rows,
"rows_after": after_rows,
"rows_removed": before_rows - after_rows,
"pct_removed": f"{(before_rows - after_rows) / before_rows:.1%}",
"note": note,
"timestamp": datetime.datetime.now().isoformat(),
}
cleaning_log.append(entry)
print(f" {step_name}: {before_rows:,} → {after_rows:,} rows ({entry['rows_removed']:,} removed)")
# Usage inside a cleaning function
def remove_duplicates_logged(df):
before = len(df)
df = df.drop_duplicates(subset=["order_id"], keep="first").reset_index(drop=True)
log_step("remove_duplicates", before, len(df), "kept most recent version")
return df
Practice Exercises¶
Warm-up
1. Write a function fix_dtypes(df) that converts: order_date to datetime, quantity to int, unit_price to float (removing £ signs and commas).
2. Chain fix_dtypes, standardise_strings, and handle_missing_values using .pipe().
3. Write a validate(df) function that checks: no duplicate order_id, no null customer_id, all quantity > 0.
Main
4. Build a complete cleaning pipeline for a sales CSV. Log the row count before and after each step.
5. Add a cleaning_summary() function that prints a before/after comparison: row count, missing values, duplicate count.
6. Add a validation step that raises an error if any assertion fails, and returns the clean DataFrame if all pass.
Stretch
7. Write a profile_data(df) function that returns a full HTML report (using df.describe(), df.dtypes, missing values, sample values per column) — something you'd share with a stakeholder.
8. Turn your cleaning pipeline into a class DataCleaner with methods for each cleaning step, a run() method that chains them all, and a report() method that prints the audit log.