Checkpoint: Cleaning & Wrangling
Reviewed & published by Brayan K
A checkpoint is a hands-on review where you combine every cleaning skill — dtypes, dropping, renaming, counting, querying, and replacing — to wrangle one messy DataFrame into shape.
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.
You'll recap the whole cleaning toolkit, tackle a multi-step build challenge with a full solution, and test yourself with a checkpoint quiz before moving on.
What You've Learned in This Unit
- The Index — set_index, reset_index, reindex
- Data types — dtypes, astype, to_numeric
- Categorical data — category dtype & ordered ranks
- Dropping rows & deduplicating with drop_duplicates
- Renaming columns & the index labels
- Counting with value_counts, unique, nunique
- Readable filtering with query() and eval()
- Replacing values with replace, where, mask, clip
1 The Mission — One Messy DataFrame
Here is a small but realistically grubby export from a sign-up form. The numbers arrived as text, there is a duplicate row, the headers are ugly, a status code needs expanding, and there is an under-age entry. Your job is to turn it into clean, analysis-ready data using the skills from this unit.
import pandas as pd
raw = pd.DataFrame({
"User Name": ["Ann", "Bob", "Ann", "Cy", "Dee"],
"Age": ["30", "15", "30", "41", "22"], # text, not numbers!
"Plan": ["P", "F", "P", "X", "F"], # codes to expand
"City": ["NYC", "LA", "NYC", "NYC", "SF"],
})🛠️ Build Challenge: Clean It Up, Step by Step
Work through these five steps. Each one uses a skill from this unit.
- Tidy the headers: lowercase and replace spaces with underscores
- Convert age from text to integers
- Drop the duplicate user row
- Expand the plan codes (P→pro, F→free, X→trial) with replace
- Keep only adults (18+) using query
import pandas as pd
raw = pd.DataFrame({
"User Name": ["Ann", "Bob", "Ann", "Cy", "Dee"],
"Age": ["30", "15", "30", "41", "22"],
"Plan": ["P", "F", "P", "X", "F"],
"City": ["NYC", "LA", "NYC", "NYC", "SF"],
})
df = raw.copy()
# 1) tidy the headers
df.columns = df.columns.str.lower().str.replace(" ", "_")
# 2) age -> integers (fill in the rest!)
df["age"] = df["age"].astype("int")
# 3) drop the duplicate user row
# 4) expand plan codes with replace({...})
# 5) keep only adults with df.query(...)
print(df)
# Build the remaining steps, then compare with the solution below.
# ✅ Expected output:
# user_name age plan city
# 0 Ann 30 P NYC
# 1 Bob 15 F LA
# 2 Ann 30 P NYC
# 3 Cy 41 X NYC
# 4 Dee 22 F SFimport pandas as pd
raw = pd.DataFrame({
"User Name": ["Ann", "Bob", "Ann", "Cy", "Dee"],
"Age": ["30", "15", "30", "41", "22"],
"Plan": ["P", "F", "P", "X", "F"],
"City": ["NYC", "LA", "NYC", "NYC", "SF"],
})
df = raw.copy()
# 1) tidy the headers
df.columns = df.columns.str.lower().str.replace(" ", "_")
# 2) age -> integers
df["age"] = df["age"].astype("int")
# 3) drop the duplicate user row (Ann appears twice)
df = df.drop_duplicates()
# 4) expand the plan codes
df["plan"] = df["plan"].replace({"P": "pro", "F": "free", "X": "trial"})
# 5) keep only adults
df = df.query("age >= 18")
print(df)
# Final result:
# user_name age plan city
# 0 Ann 30 pro NYC
# 3 Cy 41 trial NYC
# 4 Dee 22 free SF
print(df["plan"].value_counts().to_dict())
# {'pro': 1, 'trial': 1, 'free': 1}2 Verify Your Result
A clean pipeline is repeatable. Run the finished version and confirm the dtypes are right, the duplicate is gone, and only adults remain. Checking df.dtypes and len(df) at the end is a habit worth keeping.
import pandas as pd
raw = pd.DataFrame({
"User Name": ["Ann", "Bob", "Ann", "Cy", "Dee"],
"Age": ["30", "15", "30", "41", "22"],
"Plan": ["P", "F", "P", "X", "F"],
"City": ["NYC", "LA", "NYC", "NYC", "SF"],
})
df = (raw
.rename(columns=lambda c: c.lower().replace(" ", "_"))
.assign(age=lambda d: d["age"].astype("int"))
.drop_duplicates()
.replace({"P": "pro", "F": "free", "X": "trial"})
.query("age >= 18"))
print(df.dtypes["age"])
# int64
print("rows:", len(df))
# ✅ Expected output:
# int64
# rows: 3import pandas as pd
df = pd.DataFrame({"age": ["20", "9", "33"], "name": ["A", "B", "C"]})
# 🎯 YOUR TURN:
# The age column is TEXT. Convert it to integers so the next line works,
# then the adult filter will behave. Fill in the dtype name (in quotes).
df["age"] = df["age"].astype("___")
print(df.query("age >= 18"))
# Expected output:
# age name
# 0 20 A
# 2 33 C! Common Errors (And How to Fix Them)
❌ Numeric filter matches nothing
Filtering a text column with a number compares strings, not values:
df.query("age >= 18") # ❌ age is "30" (text) -> no matches✅ Fix: convert the type first:
df["age"] = df["age"].astype("int")
df.query("age >= 18") # ✅❌ Cleaning steps quietly lost
Each step returns a new frame; without reassignment they vanish:
df.drop_duplicates()
df.replace({"P": "pro"}) # ❌ neither change is saved✅ Fix: reassign or chain:
df = df.drop_duplicates().replace({"P": "pro"}) # ✅📝 Checkpoint Quiz
Answer each in your head, then expand to check.
df.set_index("col"). Use reset_index() to push it back to an ordinary column.
At least one value is non-numeric text, so pandas stored the column as strings. Clean it and convert with astype, or use pd.to_numeric(col, errors="coerce") to turn bad values into NaN.
Sort by the timestamp, then df.drop_duplicates(subset=["email"], keep="last"). The subset judges duplicates by email; keep="last" retains the most recent.
Proportions that sum to 1 instead of raw counts — each category's share of the column. Multiply by 100 for percentages.
Prefix it with @: df.query("age > @cutoff"). Without the @, pandas looks for a column called cutoff.
where keeps the original value where cond is True and inserts 0 where it is False. mask is the opposite: it inserts 0 where cond is True. Mask hides (replaces) the True cells.
❓ Frequently Asked Questions
Checkpoint cleared — you can wrangle messy data!
You combined dtype fixes, deduplication, renaming, code expansion, and filtering into one repeatable pipeline — the everyday work of real data cleaning.
Practice quiz
Which method promotes an ordinary column to become the row index?
- df.reset_index("col")
- df.set_index("col")
- df.reindex("col")
- df.index("col")
Answer: df.set_index("col"). df.set_index("col") moves a column into the index. reset_index() pushes it back to a normal column.
A column of numbers shows dtype "object". What is the usual cause?
- The values are stored as strings
- The column has too many rows
- The index is not sorted
- The column name has spaces
Answer: The values are stored as strings. object dtype means at least one value is text, so pandas stored the whole column as strings. Convert with astype or to_numeric.
Your numeric filter df.query("age >= 18") returned no rows on clean-looking data. Why?
- age is still text (object dtype)
- query() does not support >=
- 18 must be quoted
- You forgot to sort first
Answer: age is still text (object dtype). If age is still object dtype, the string "30" is not greater than the number 18, so the comparison silently matches nothing.
Which call safely converts bad values to NaN instead of raising during conversion?
- astype("int")
- pd.to_numeric(col, errors="coerce")
- col.replace(int)
- col.fillna("int")
Answer: pd.to_numeric(col, errors="coerce"). pd.to_numeric(col, errors="coerce") turns non-numeric values into NaN rather than throwing, unlike astype.
To keep one row per email, retaining the newest, what do you do after sorting by timestamp?
- df.drop_duplicates(subset=["email"], keep="last")
- df.drop_duplicates(keep="first")
- df.dropna(subset=["email"])
- df.unique("email")
Answer: df.drop_duplicates(subset=["email"], keep="last"). With the data sorted by time, drop_duplicates(subset=["email"], keep="last") keeps the most recent record per email.
What does value_counts(normalize=True) return?
- Raw integer counts
- Proportions that sum to 1
- The unique values only
- A sorted index
Answer: Proportions that sum to 1. normalize=True returns each category share as a proportion summing to 1; multiply by 100 for percentages.
Inside a query string, how do you reference a Python variable named cutoff?
- df.query("age > cutoff")
- df.query("age > @cutoff")
- df.query("age > $cutoff")
- df.query("age > {cutoff}")
Answer: df.query("age > @cutoff"). Prefix the variable with @: df.query("age > @cutoff"). Without @, pandas looks for a column named cutoff.
What is the difference between df.where(cond, 0) and df.mask(cond, 0)?
- They are identical
- where keeps values where cond is True; mask replaces values where cond is True
- where only works on numbers
- mask drops rows
Answer: where keeps values where cond is True; mask replaces values where cond is True. where keeps the original where cond is True (inserting 0 where False); mask is the opposite, inserting 0 where cond is True.
Which method expands code values, e.g. {"P": "pro", "F": "free"}, across a column?
- df["plan"].replace({...})
- df["plan"].astype({...})
- df["plan"].rename({...})
- df["plan"].query({...})
Answer: df["plan"].replace({...}). Series.replace({...}) maps each old value to its replacement, expanding short codes into readable labels.
Why must cleaning steps like df.drop_duplicates() be reassigned or chained?
- They run too slowly otherwise
- They return a new frame; without reassignment the change is lost
- They modify the index only
- They require strict mode
Answer: They return a new frame; without reassignment the change is lost. Most cleaning methods return a new DataFrame and do not mutate in place, so you must reassign (df = ...) or chain them.
Continue this course
- Previous: Replacing Values: replace, where & mask
- Next: Binning Continuous Data: cut & qcut — Group numbers into ranges with cut for fixed edges and qcut for quantiles
- Quick reference: Pandas cheat sheet
Frequently asked questions
What order should I clean a messy DataFrame in?
A reliable order is: fix dtypes first (so comparisons and math behave), then drop duplicates and unwanted rows, rename and tidy labels, replace or coerce bad values, and filter last. Doing dtypes early prevents subtle bugs — for example, '30' as text never satisfies a numeric > 18 test until it is converted.
Why did my numeric filter return nothing on cleaned-looking data?
Almost always because the column is still text (object dtype). The string '30' is not greater than the number 18, so the comparison silently matches nothing. Run df.dtypes, convert the column with astype or pd.to_numeric, then filter again.
Should I chain all my cleaning steps or do them one by one?
Both work. Beginners benefit from one step per line with a print() after each so they can see the effect. As you gain confidence, method chaining (df.dropna().drop_duplicates().rename(...)) reads cleanly and avoids intermediate variables. Either way, assign the final result back to a variable.