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
Every fill decision here is a judgment call, not a certainty
Per 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))
Never overwrite the raw file
The pipeline reads from 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.

Extend This Project

Try these on your own:

  • Turn the printed report into a saved cleaning_report.txt file 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_names handles 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