Data cleaning is the work of turning raw records into data you can trust: the right columns, the right types, explained gaps and no accidental duplicates. Real exports nearly always need it, because people type "N/A" into number columns, systems export dates in different formats, and the same record arrives twice.
import pandas as pd
raw = pd.read_csv("donations.csv", dtype=str)
raw["amount"] = pd.to_numeric(raw["amount"], errors="coerce") # "bad" becomes NaNThis lesson follows a disciplined process: define what valid data looks like, convert values explicitly while keeping a record of what was rejected and why, handle missing values and duplicates according to what they mean, and reconcile the result before analysing it. The examples clean donation records for a community food bank, using pandas.
Data contracts
Before transforming anything, write down what each column must contain. This data contract is what the cleaning code checks:
| Column | Required | Type | Rule |
|---|---|---|---|
donation_id | yes | integer | unique |
date | yes | date | not in the future |
amount | yes | decimal | greater than 0 |
method | yes | text | one of cash, card, transfer |
expected = {"donation_id", "date", "amount", "method"}
missing = expected - set(raw.columns)
if missing:
raise ValueError(f"Export is missing columns: {sorted(missing)}")A file loading without errors proves only that it was readable, not that its contents make sense: a CSV with every amount set to 0, or dates shifted by a year, loads just as smoothly. Checking the contract first turns silent problems into explicit ones. Reading everything as text initially (dtype=str) keeps the raw values visible, so conversion problems can be reported rather than guessed at.
Missing and duplicates
Missing values need a policy based on what the gap means:
- A missing
amountmakes a donation unusable for totals: reject the row and report it. - A missing optional
noteis harmless: keep it. - A missing
methodmight be filled with"unknown"if the analysis can live with that category.
Duplicates need the same care. Two rows that are identical in every column, including donation_id, are almost certainly an export glitch. Two donations of £20 on the same day from different donors are legitimate and must both stay. Decide which columns define "the same record":
dupes = df.duplicated(subset=["donation_id"], keep="first")
print(dupes.sum(), "repeated ids")
df = df[~dupes]Deduplicating on amount and date alone would wrongly delete real donations. Always count how many rows each rule removes, so the effect is visible in the report rather than hidden in the totals.
Conversion and provenance
Convert each column explicitly, and keep a record of rows that fail, together with the reason. That record is the cleaning step's provenance:
amount = pd.to_numeric(raw["amount"], errors="coerce")
date = pd.to_datetime(raw["date"], format="%Y-%m-%d", errors="coerce")
problems = pd.Series("", index=raw.index)
problems[amount.isna()] += "amount not a number; "
problems[amount <= 0] += "amount not positive; "
problems[date.isna()] += "bad date; "
problems[~raw["method"].isin(["cash", "card", "transfer"])] += "unknown method; "
rejected = raw[problems != ""].assign(reason=problems[problems != ""])errors="coerce" turns invalid values into NaN or NaT instead of raising, which lets you find them all at once. Coercion alone is dangerous, though: if you then drop or fill the NaNs without recording which inputs they came from, the source problems vanish. Keeping rejected with a reason column lets someone fix the source system, and lets you answer "why isn't donation 1042 in the total?"
Normalise text before validating it: str.strip() and str.lower() turn " Card " into "card", so formatting differences aren't mistaken for invalid values.
Analysis checks
Before presenting any result, reconcile:
- Row counts: accepted + rejected + removed duplicates = original rows.
- Totals: the cleaned total plus the rejected amounts should explain the raw total where amounts were readable.
- Ranges: minimum and maximum dates and amounts are plausible.
assert len(clean) + len(rejected) + duplicates_removed == len(raw), "rows unaccounted for"
print(clean["amount"].describe())
print(clean["date"].min(), clean["date"].max())These checks catch bugs in the cleaning code itself, such as a filter that accidentally drops valid rows, which a chart would never reveal. A clean-looking bar chart built on an incorrect policy is still wrong.
Make the process repeatable: put the cleaning steps in a function or script, keep the raw file unchanged, and write the cleaned data and the rejection report as separate outputs. Running the same code on next month's export then produces comparable results.
Independent practice and a worked check
Task: clean donation records while reconciling source, accepted and rejected counts and documenting every policy.
import pandas as pd
raw = pd.DataFrame({"id": ["1", "2", "3", "3", "4"],
"amount": ["10", "n/a", "25", "25", "0"]})
policies = ["duplicate ids keep the first row",
"non-numeric or non-positive amounts are rejected"]
dupes = raw.duplicated("id")
data = raw[~dupes]
amount = pd.to_numeric(data["amount"], errors="coerce")
valid = amount > 0 # NaN compares as False
clean, rejected = data[valid], data[~valid]
assert len(clean) + len(rejected) + dupes.sum() == len(raw)
print(len(clean), len(rejected), int(dupes.sum())) # 2 2 1
print(amount[valid].sum()) # 35.0
print(*policies, sep="\n")For the worked check, rejected row information is retained to explain cleaning decisions: it shows which records were excluded, why, and lets the totals be reconciled.
Reference and next practice
The pandas user guide covers working with missing data and duplicate labels, and the to_numeric reference explains errors=. Continue with statistics and data visualization. Practise with the Python exercises and Python quiz.
Data Cleaning and Analysis Summary
- Data contracts: Define expected columns, types and allowed values before transforming rows.
- Missing and duplicates: Missing data and duplicates need policies based on the source meaning.
- Conversion and provenance: Convert values explicitly and preserve enough information to explain rejected rows.
- Analysis checks: Reconcile row counts and totals before presenting a summary.
Data Cleaning and Analysis Examples
Each example shows the code, the output it produces and what each part does.
Data Cleaning and Analysis in a small program
import pandas as pd
raw = pd.DataFrame({"id": [1, 2, 3], "amount": ["12", "bad", "8"]})
amounts = pd.to_numeric(raw["amount"], errors="coerce")
valid = amounts.notna()
print(int(valid.sum()))
print(amounts[valid].sum())2
20.0Line-by-line explanation
- Line 3
to_numeric(..., errors="coerce")converts the text, turning"bad"intoNaNinstead of raising. - Lines 4 to 5A boolean mask marks the rows that converted successfully; two of the three did.
- Line 6Only valid amounts are summed: 12 + 8.
A complete cleaning step with a rejection report
import io
import pandas as pd
csv_text = """donation_id,date,amount,method
1,2026-03-02,20,cash
2,2026-03-02,20,Card
3,2026-03-03,abc,card
4,2026-13-01,15,transfer
5,2026-03-04,-5,cash
2,2026-03-02,20,Card
6,2026-03-05,40, Transfer
"""
raw = pd.read_csv(io.StringIO(csv_text), dtype=str)
dupes = raw.duplicated(subset=["donation_id"], keep="first")
data = raw[~dupes].copy()
data["method"] = data["method"].str.strip().str.lower()
amount = pd.to_numeric(data["amount"], errors="coerce")
date = pd.to_datetime(data["date"], format="%Y-%m-%d", errors="coerce")
reasons = pd.Series("", index=data.index)
reasons[amount.isna()] += "amount not a number; "
reasons[amount <= 0] += "amount not positive; "
reasons[date.isna()] += "bad date; "
reasons[~data["method"].isin(["cash", "card", "transfer"])] += "unknown method; "
ok = reasons == ""
clean = data[ok].assign(amount=amount[ok], date=date[ok])
rejected = data[~ok].assign(reason=reasons[~ok].str.rstrip("; "))
print(f"raw {len(raw)} = clean {len(clean)} + rejected {len(rejected)} + duplicates {int(dupes.sum())}")
print(rejected[["donation_id", "reason"]].to_string(index=False))
print("Total donated:", clean["amount"].sum())
print(clean.groupby("method")["amount"].sum().to_dict())raw 7 = clean 3 + rejected 3 + duplicates 1
donation_id reason
3 amount not a number
4 bad date
5 amount not positive
Total donated: 80.0
{'card': 20.0, 'cash': 20.0, 'transfer': 40.0}Line-by-line explanation
- Lines 15 to 16Repeated
donation_id2 is removed; the two £20 donations on 2 March with different ids both stay. - Line 18Normalising whitespace and case turns
CardandTransferinto valid methods. - Lines 23 to 26Each rule appends its reason, so a row failing several checks lists them all.
- Line 32The reconciliation line proves every raw row is accounted for.
Common Data Cleaning and Analysis Mistakes
Coercing errors and silently dropping them
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
df = df.dropna() # bad rows disappear without a trace
print(df["amount"].sum())amount = pd.to_numeric(df["amount"], errors="coerce")
rejected = df[amount.isna()]
print(f"{len(rejected)} rows rejected: {rejected['donation_id'].tolist()}")
df = df[amount.notna()].assign(amount=amount[amount.notna()])Deduplicating on the wrong columns
df = df.drop_duplicates(subset=["date", "amount"]) # deletes genuine repeat donationsdf = df.drop_duplicates(subset=["donation_id"]) # same id = same recordData Cleaning and Analysis in Real Projects
Data cleaning in real projects
Data analysts commonly report spending a large share of their time preparing data. Charities reconcile donation exports against bank statements, shops clean product feeds before uploading them to marketplaces, and researchers document every exclusion in their methods section. In each case the rejected-rows report is as valuable as the clean data, because it lets someone fix problems at the source.
Larger pipelines formalise the contract with validation libraries such as pandera, Great Expectations or Pydantic, run cleaning automatically on each new file, and alert when rejection rates jump, a common sign that an upstream system changed its export format.
Data Cleaning and Analysis FAQ
What are the main steps of data cleaning in Python?
Check the columns against what you expect, convert types explicitly, handle missing values and duplicates with stated policies, normalise text, record rejected rows with reasons, and reconcile counts and totals before analysis.
What does errors="coerce" do in pandas?
In pd.to_numeric() and pd.to_datetime(), it turns values that can't be converted into NaN or NaT instead of raising an error. Use it to find every bad value at once, then report those rows rather than silently dropping them.
Should I fill missing values or drop them?
It depends on what the gap means. Drop or reject rows when the missing field makes the record unusable, fill when there's a defensible default, and keep the gap when "unknown" is a meaningful answer. Record the choice and how many rows it affected.
How do I remove duplicate rows in pandas?
df.drop_duplicates() removes rows identical in every column; df.drop_duplicates(subset=["id"]) treats rows with the same id as duplicates. Check df.duplicated(...).sum() first to see how many rows will go.
Why read a CSV with dtype=str before cleaning?
Reading everything as text preserves the raw values exactly, including leading zeros and invalid entries, so your cleaning code controls every conversion and can report precisely what failed.
Data Cleaning and Analysis Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Why retain rejected row information?
Which review concern is valid for data cleaning and analysis?
Data Cleaning and Analysis Quiz
2 questions to check what you learned.
Why retain rejected row information?
- To increase the reported total
- To explain cleaning decisions
- Python ignores the distinction
Which statement correctly describes conversion and provenance?
- Convert values explicitly and preserve enough information to explain rejected rows.
- Only the example output matters, not the rule
- Every operation has the same behavior