Capstone: End-to-End Data Cleaning

Reviewed & published by Brayan K

A data cleaning pipeline is the ordered sequence of pandas steps that turns a messy raw file into an analysis-ready table — reading the data, inspecting it, fixing strings and dates, handling missing values, fixing dtypes, deriving new columns, filtering, summarising, and exporting the result. This capstone combines every skill from the course into one realistic workflow.

Part of the free Pandas course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Practice in This Capstone

1 Step 1 — Load and Inspect the Mess

Always look before you clean. head() shows the shape of the data, info() reveals dtypes and null counts, and isna().sum() tells you exactly where the gaps are. Notice the messy strings, mixed casing, and missing values below.

import pandas as pd
import io

raw = """order_id,customer, signup_date ,amount,country
1001,  Alice  ,2023-01-05,49.99,uk
1002,bob,2023/02/11,,US
1003,CAROL,not a date,19.50,uk
1004,  dan,2023-03-20,75.00,
1005,Alice,2023-01-05,49.99,UK
"""

df = pd.read_csv(io.StringIO(raw))

print(df.head())
print("--- info ---")
df.info()                 # dtypes + non-null counts (amount has a missing value)
print("--- missing per column ---")
print(df.isna().sum())    # amount: 1 missing, country: 1 missing
# Also note: messy whitespace and casing in 'customer' and 'country',
# and a bad value 'not a date' in signup_date.

# ✅ Expected output:
#    order_id   customer  signup_date   amount country
# 0      1001    Alice      2023-01-05   49.99      uk
# 1      1002        bob    2023/02/11     NaN      US
# 2      1003      CAROL    not a date   19.50      uk
# 3      1004        dan    2023-03-20   75.00     NaN
# 4      1005      Alice    2023-01-05   49.99      UK
# --- info ---
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 5 entries, 0 to 4
# Data columns (total 5 columns):
#  #   Column         Non-Null Count  Dtype  
# ---  ------         --------------  -----  
#  0   order_id       5 non-null      int64  
#  1   customer       5 non-null      object 
#  2    signup_date   5 non-null      object 
#  3   amount         4 non-null      float64
#  4   country        4 non-null      object 
# dtypes: float64(1), int64(1), object(3)
# memory usage: 332.0+ bytes
# --- missing per column ---
# order_id         0
# customer         0
#  signup_date     0
# amount           1
# country          1
# dtype: int64

2 Step 2 — Clean Strings & Parse Dates

Strip whitespace and normalise casing with the .str accessor. Parse dates with pd.to_datetime(..., errors="coerce") so unparseable values become NaT instead of crashing.

import pandas as pd
import io

raw = """order_id,customer, signup_date ,amount,country
1001,  Alice  ,2023-01-05,49.99,uk
1002,bob,2023/02/11,,US
1003,CAROL,not a date,19.50,uk
"""
df = pd.read_csv(io.StringIO(raw))

# Tidy column names (strip surrounding spaces)
df.columns = df.columns.str.strip()

# Clean the customer text: strip spaces, Title Case
df["customer"] = df["customer"].str.strip().str.title()

# Normalise country codes to uppercase
df["country"] = df["country"].str.strip().str.upper()

# Parse dates; bad strings -> NaT (Not a Time) instead of an error
df["signup_date"] = pd.to_datetime(df["signup_date"], errors="coerce")

print(df)
print(df.dtypes)
# 'Alice', 'Bob', 'Carol'; 'UK'/'US'; signup_date is datetime64 with one NaT.

# ✅ Expected output:
#    order_id customer signup_date  amount country
# 0      1001    Alice  2023-01-05   49.99      UK
# 1      1002      Bob         NaT     NaN      US
# 2      1003    Carol         NaT   19.50      UK
# order_id                int64
# customer               object
# signup_date    datetime64[ns]
# amount                float64
# country                object
# dtype: object

3 Step 3 — The Complete Pipeline

Here is the whole workflow end to end: load → clean → handle missing → fix dtypes → derive a column → filter → summarise → export. This is the kind of script you would run on a real messy export.

import pandas as pd
import io

# ----- 0) Raw messy data (in real life: a file path or URL) -----
raw = """order_id,customer, signup_date ,amount,country
1001,  Alice  ,2023-01-05,49.99,uk
1002,bob,2023/02/11,,US
1003,CAROL,2023-02-28,19.50,uk
1004,  dan,2023-03-20,75.00,
1005,Alice,2023-01-05,49.99,UK
1005,Alice,2023-01-05,49.99,UK
"""

df = pd.read_csv(io.StringIO(raw))
print("Loaded:", df.shape, "rows x cols")

# ----- 1) Tidy column names -----
df.columns = df.columns.str.strip()

# ----- 2) Clean string columns with the .str accessor -----
df["customer"] = df["customer"].str.strip().str.title()
df["country"] = df["country"].str.strip().str.upper()

# ----- 3) Parse dates (bad values -> NaT) -----
df["signup_date"] = pd.to_datetime(df["signup_date"], errors="coerce")

# ----- 4) Fix numeric dtype and handle missing values -----
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
df["amount"] = df["amount"].fillna(df["amount"].median())  # impute missing amount
df["country"] = df["country"].fillna("UNKNOWN")            # placeholder for missing

# ----- 5) Drop exact duplicate rows (order 1005 appears twice) -----
df = df.drop_duplicates()

# ----- 6) Derive a new column -----
df["is_high_value"] = df["amount"] >= 50

# ----- 7) Downcast for memory; country is low-cardinality -----
df["order_id"] = df["order_id"].astype("int32")
df["country"] = df["country"].astype("category")

# ----- 8) Filter: keep only valid, high-value orders -----
clean = df[df["is_high_value"] & df["signup_date"].notna()].copy()

# ----- 9) Summarise: total amount per country -----
summary = (df.groupby("country", observed=True)["amount"]
             .agg(["count", "sum", "mean"])
             .round(2))

print("--- Cleaned data ---")
print(df)
print("--- High-value orders with a valid date ---")
print(clean[["order_id", "customer", "amount"]])
print("--- Summary by country ---")
print(summary)

# ----- 10) Export the cleaned result to CSV text -----
out = io.StringIO()
df.to_csv(out, index=False)
print("--- Exported CSV (first lines) ---")
print(out.getvalue()[:160])

# EXPECTED HIGHLIGHTS:
#  - Shape becomes 5 rows after the duplicate 1005 is dropped.
#  - Order 1002's missing amount is filled with the median (49.99).
#  - Order 1004's missing country becomes 'UNKNOWN'.
#  - 'is_high_value' is True for the 49.99 and 75.00 orders.
#  - Summary shows count/sum/mean of amount per country.

# ✅ Expected output:
# Loaded: (6, 5) rows x cols
# --- Cleaned data ---
#    order_id customer signup_date  amount  country  is_high_value
# 0      1001    Alice  2023-01-05   49.99       UK          False
# 1      1002      Bob         NaT   49.99       US          False
# 2      1003    Carol  2023-02-28   19.50       UK          False
# 3      1004      Dan  2023-03-20   75.00  UNKNOWN           True
# 4      1005    Alice  2023-01-05   49.99       UK          False
# --- High-value orders with a valid date ---
#    order_id customer  amount
# 3      1004      Dan    75.0
# --- Summary by country ---
#          count     sum   mean
# country                      
# UK           3  119.48  39.83
# UNKNOWN      1   75.00  75.00
# US           1   49.99  49.99
# --- Exported CSV (first lines) ---
# order_id,customer,signup_date,amount,country,is_high_value
# 1001,Alice,2023-01-05,49.99,UK,False
# 1002,Bob,,49.99,US,False
# 1003,Carol,2023-02-28,19.5,UK,False
# 100

🎯 Your Turn: Fill the Missing Prices

Replace the missing prices with 0 so the column has no gaps. Fill in the blank method.

import pandas as pd
import io

raw = """item,price
Pen,1.20
Pad,
Eraser,0.50
Ruler,
"""
df = pd.read_csv(io.StringIO(raw))

# 🎯 YOUR TURN: replace every missing price with 0
df["price"] = df["price"].___(0)
print(df)
print("Missing left:", df["price"].isna().sum())

# EXPECTED OUTPUT:
#      item  price
# 0     Pen   1.20
# 1     Pad   0.00
# 2  Eraser   0.50
# 3   Ruler   0.00
# Missing left: 0

📋 The Data Cleaning Checklist

StepTool
Loadpd.read_csv(...)
Inspecthead() / info() / isna().sum()
Clean strings.str.strip().str.title()
Parse datespd.to_datetime(..., errors="coerce")
Missing datafillna() / dropna()
Fix dtypesto_numeric() / astype()
Derive & filterdf["new"] = ... ; df[mask]
Summarisegroupby().agg()
Exportdf.to_csv(...)

🏆 Final Challenge: Clean a Survey Export

Apply the whole pipeline to a small messy survey: clean the names, normalise the rating column, drop rows with no rating, then report the average rating per city.

import pandas as pd
import io

raw = """name, city ,rating
 Alice ,London,5
bob,london,
CAROL, LEEDS ,4
dan,London,3
 eve , leeds ,5
"""

df = pd.read_csv(io.StringIO(raw))

# 1) Tidy headers + string columns
df.columns = df.columns.str.strip()
df["name"] = df["name"].str.strip().str.title()
df["city"] = df["city"].str.strip().str.title()   # 'london' -> 'London'

# 2) Make rating numeric; blanks -> NaN, then drop those rows
df["rating"] = pd.to_numeric(df["rating"], errors="coerce")
df = df.dropna(subset=["rating"])

# 3) Average rating per city
result = df.groupby("city")["rating"].mean().round(2)
print(df)
print(result)
# EXPECTED: London avg = (5 + 3) / 2 = 4.0, Leeds avg = (4 + 5) / 2 = 4.5
# (Bob's blank-rating row was dropped.)

# ✅ Expected output:
#     name    city  rating
# 0  Alice  London     5.0
# 2  Carol   Leeds     4.0
# 3    Dan  London     3.0
# 4    Eve   Leeds     5.0
# city
# Leeds     4.5
# London    4.0
# Name: rating, dtype: float64

❓ Frequently Asked Questions

Capstone complete — you finished the Pandas course!

You took raw, messy data and ran it through a full professional pipeline: load, inspect, clean strings and dates, handle missing values, fix dtypes, derive columns, filter, summarise, and export. Combined with everything earlier — Series, DataFrames, selection, filtering, aggregation, merging, reshaping, MultiIndex, and performance — you now have the complete toolkit of a working data analyst.

🎓 Congratulations! Take these skills to a real dataset and keep practising.

Practice quiz

What does io.StringIO let you do in these cleaning examples?

  • Read CSV text from an in-memory string as if it were a file
  • Download files from the web
  • Compress data
  • Connect to a database

Answer: Read CSV text from an in-memory string as if it were a file. io.StringIO wraps a string so pd.read_csv can read CSV text defined right in the script.

Which trio of calls is the standard first step to inspect raw data?

  • head(), info(), isna().sum()
  • drop(), rename(), sort()
  • merge(), concat(), pivot()
  • plot(), hist(), bar()

Answer: head(), info(), isna().sum(). head shows the shape, info shows dtypes and null counts, and isna().sum() locates the gaps.

What does errors='coerce' do in pd.to_datetime or pd.to_numeric?

  • Raises on the first bad value
  • Turns unparseable values into NaT/NaN instead of raising
  • Deletes the bad rows
  • Ignores the column

Answer: Turns unparseable values into NaT/NaN instead of raising. coerce converts values it cannot parse into NaT or NaN so you can handle them deliberately.

To strip whitespace and Title-Case a text column, which accessor do you use?

  • .dt
  • .cat
  • .str
  • .loc

Answer: .str. The .str accessor exposes string methods like .str.strip().str.title().

Which method replaces missing values with a chosen fill, like 0 or a median?

  • dropna()
  • fillna()
  • drop_duplicates()
  • reset_index()

Answer: fillna(). fillna substitutes a value for NaN; dropna removes rows/columns instead.

When is dropna a better choice than fillna for missing values?

  • When a sensible default exists
  • When the row is unusable without the value, like a missing key
  • Always
  • Never

Answer: When the row is unusable without the value, like a missing key. Drop rows that cannot be used without the value; fill when a reasonable default exists.

Which method removes exact duplicate rows from a DataFrame?

  • drop_duplicates()
  • unique()
  • distinct()
  • dedupe()

Answer: drop_duplicates(). drop_duplicates() collapses repeated identical rows.

Why convert a low-cardinality column with astype('category') during cleaning?

  • To make it editable
  • To save memory and speed up grouping
  • To parse dates
  • To drop nulls

Answer: To save memory and speed up grouping. Category stores repeated labels once with small integer codes, saving memory and speeding grouping.

Which method exports a cleaned DataFrame to CSV text or a file?

  • to_csv()
  • read_csv()
  • to_datetime()
  • save()

Answer: to_csv(). df.to_csv writes the DataFrame out as CSV.

What is the correct overall order of a cleaning pipeline?

  • Export, then load, then inspect
  • Load, inspect, clean, fix dtypes, derive, filter, summarise, export
  • Summarise first, then load
  • Filter before loading

Answer: Load, inspect, clean, fix dtypes, derive, filter, summarise, export. You load and inspect first, then clean and transform, and export the result last.

Continue this course

Frequently asked questions

What is io.StringIO and why use it in these examples?

io.StringIO wraps an in-memory string so it behaves like a file object. That lets pd.read_csv read CSV text defined right in your script — perfect for self-contained, runnable examples and tests without needing a real file on disk. In production you would pass an actual file path or URL to read_csv instead.

Should I use fillna or dropna for missing values?

It depends on the column and your goal. Use dropna to remove rows or columns that are unusable without the value (for example a missing primary key). Use fillna when a sensible default exists — 0 for missing counts, the median for a skewed numeric column, or a placeholder like 'Unknown' for a category. Decide per column rather than globally.

Why convert columns to the right dtype during cleaning?

Raw CSV data often arrives as strings. Converting with to_datetime, to_numeric, or astype lets pandas do date math, numeric aggregation, and efficient storage. It also surfaces bad values early (with errors='coerce' turning unparseable entries into NaN/NaT) so you can handle them deliberately rather than having them silently break later steps.