Data Cleaning Pipeline: Wrangling a Messy Real-World CSV
Data Science & ML Projects (Beginner)
Chapter 2 · Data Cleaning Pipeline: Wrangling a Messy Real-World CSV
ds1-4 worked through cleaning as a one-time notebook exercise on one small, illustrative dataset. This chapter turns that same toolkit — missing values, duplicates, type conversion, string cleaning, outliers — into a real, reusable pipeline: a set of functions that can be pointed at any messy CSV and will both clean it and explain, in plain language, exactly what it did.
What We're Building
A clean_pipeline(df) function that takes a raw, messy customer-orders CSV, returns a cleaned DataFrame, and generates a human-readable cleaning report — a running log of every fix it made and why.
# customer_orders.csv — real-world messy, on purpose order_id,customer_name,email,order_date,amount,country 1001," john smith ",john@example.com,2026-01-04,"$45.00",USA 1002,JANE DOE,jane@example.com,01/05/2026,"$120",uk 1003,Bob Lee,bob@example.com,2026-01-06,,USA 1001," john smith ",john@example.com,2026-01-04,"$45.00",USA 1004,Amy Chen,amy@example.com,2026-01-07,"$999999.99", 1005,dan miller,,2026-01-08,"eror",USA
Step 1: Load & Inspect First
import pandas as pd df = pd.read_csv("customer_orders.csv") print(df.info()) print(df.isna().sum()) print(df.duplicated().sum())
Same diagnostic reflex ds1-3/ds1-4 already established — .info() for dtypes, .isna().sum() for missing values per column, .duplicated().sum() for exact duplicate rows. Never clean before looking; the shape of the mess determines which fixes actually apply.
Step 2: Duplicates
def remove_duplicates(df, report): before = len(df) df = df.drop_duplicates() removed = before - len(df) report.append(f"Removed {removed} exact duplicate row(s).") return df
Every cleaning function here follows the same shape: do the fix, measure what changed, append a plain-English line to a shared report list. Row 1001 above is an exact duplicate — same order, same everything — the safest kind of row to simply drop.
Step 3: Fix Types & Formats
def fix_amount(df, report): before_missing = df["amount"].isna().sum() df["amount"] = ( df["amount"].astype(str) .str.replace("$", "", regex=False) ) df["amount"] = pd.to_numeric(df["amount"], errors="coerce") # "eror" -> NaN coerced = df["amount"].isna().sum() - before_missing report.append(f"Converted amount to numeric; {coerced} unparseable value(s) became missing.") return df def fix_dates(df, report): df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce") report.append("Standardized order_date to a single datetime format.") return df
errors="coerce" is the load-bearing choice in both functions — instead of crashing on "eror" or an inconsistent date format, pandas turns anything it can't parse into NaN, which Step 5 then handles deliberately rather than letting a bad value silently corrupt a calculation.
Step 4: Standardize Text
def standardize_names(df, report): df["customer_name"] = df["customer_name"].str.strip().str.title() df["country"] = df["country"].str.strip().str.upper() report.append("Trimmed whitespace and standardized casing on customer_name and country.") return df
" john smith " and "JANE DOE" are two customers written two very different ways — .str.strip() removes stray whitespace, .str.title() gives every name the same casing convention, so two records for the same person don't silently look like two different customers to anything downstream.
Step 5: Missing Values — A Real Decision, Per Column
def handle_missing(df, report): df = df.dropna(subset=["customer_name"]) # can't analyze an order with no customer report.append("Dropped rows with a missing customer_name (unrecoverable identifier).") median_amount = df["amount"].median() n_filled = df["amount"].isna().sum() df["amount"] = df["amount"].fillna(median_amount) report.append(f"Filled {n_filled} missing amount value(s) with the median (${median_amount:.2f}).") n_unknown = df["country"].isna().sum() df["country"] = df["country"].fillna("UNKNOWN") report.append(f"Filled {n_unknown} missing country value(s) with 'UNKNOWN'.") return df
ds1-4, there's no universally correct way to handle a missing value — dropping loses a whole row's worth of other good data; filling invents a value that was never actually observed. This pipeline drops rows missing a customer name (nothing useful can be salvaged), but fills missing amounts with the median (a reasonable estimate) and missing countries with an explicit "UNKNOWN" label rather than a silent guess. A different dataset, or a different downstream use, could reasonably justify different choices — which is exactly why the report logs each decision instead of making it invisibly.
Step 6: Flagging (Not Silently Removing) Outliers
def flag_outliers(df, report): q1, q3 = df["amount"].quantile([0.25, 0.75]) iqr = q3 - q1 upper_bound = q3 + 1.5 * iqr # the same IQR rule ds1-6 covers in full flagged = df[df["amount"] > upper_bound] report.append(f"Flagged {len(flagged)} row(s) as statistical outliers (amount > ${upper_bound:.2f}) for manual review — not removed.") return df, flagged
$999999.99 is almost certainly a data-entry error, not a genuine order — but this pipeline flags it for a human to check rather than deleting it automatically. ds1-6 covers the IQR rule's own full statistical reasoning; here it's applied as a practical, reusable check.
The Complete Pipeline
def clean_pipeline(input_path): df = pd.read_csv(input_path) report = [f"Started with {len(df)} rows."] df = remove_duplicates(df, report) df = fix_amount(df, report) df = fix_dates(df, report) df = standardize_names(df, report) df = handle_missing(df, report) df, flagged = flag_outliers(df, report) report.append(f"Finished with {len(df)} rows.") return df, flagged, report cleaned, flagged, report = clean_pipeline("customer_orders.csv") cleaned.to_csv("customer_orders_cleaned.csv", index=False) print("\n".join(report))
customer_orders.csv and writes to a new file, customer_orders_cleaned.csv. Every cleaning decision here — the median fill, the "UNKNOWN" label, the outlier threshold — is a choice that could turn out to be wrong in hindsight. Keeping the original file untouched means any of these decisions can be revisited later without needing to re-collect the data.
errors="coerce"
Turns unparseable values into NaN instead of crashing the whole conversion.
Per-column missing-value strategy
Drop, fill, or label "UNKNOWN" — a real decision made once, per column, and logged.
Flag, don't silently delete
Outliers get marked for review, not removed automatically.
Self-documenting pipelines
Every cleaning function appends to a shared report — nothing happens invisibly.
Try these on your own:
- Turn the printed report into a saved
cleaning_report.txtfile alongside the cleaned CSV, so the log survives the run that produced it. - Add a function that standardizes email addresses (lowercase, strip whitespace) the same way
standardize_nameshandles names. - Make
clean_pipeline()accept a config dict specifying which columns to drop-on-missing versus fill-on-missing, instead of hardcoding the choice per column. - Run the pipeline against the flagged-outlier rows only, and decide by hand whether each one should be corrected, dropped, or kept as a genuine (if unusual) order.
What's Next
Chapter 3: Weather Data Dashboard — the first project pulling live data from an API instead of a static file, applying ds1-3's own JSON-handling material to real-time responses.
Chapter 2 Quick Reference
- Turns ds1-4's own one-time cleaning walkthrough into a real, reusable, function-based pipeline
- Six steps: duplicates → type/format fixes → text standardization → missing values → outlier flagging → a generated report
errors="coerce"converts bad values to NaN instead of crashing — handled deliberately downstream, not silently- Missing-value strategy is a real per-column judgment call, always logged, never invisible
- Outliers are flagged for review, not auto-deleted; the IQR rule itself is covered in full in ds1-6
- Always write cleaned output to a new file — never overwrite the raw source