Python Data Cleaning and Analysis

Turn messy records into trustworthy data: validate against a contract, convert types, handle gaps and duplicates, report rejected rows and reconcile counts.

Lesson 78 of 107Advanced12 min read

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.

Python
import pandas as pd
raw = pd.read_csv("donations.csv", dtype=str)
raw["amount"] = pd.to_numeric(raw["amount"], errors="coerce")   # "bad" becomes NaN

This 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:

ColumnRequiredTypeRule
donation_idyesintegerunique
dateyesdatenot in the future
amountyesdecimalgreater than 0
methodyestextone of cash, card, transfer
Python
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 amount makes a donation unusable for totals: reject the row and report it.
  • A missing optional note is harmless: keep it.
  • A missing method might 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":

Python
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:

Python
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:

  1. Row counts: accepted + rejected + removed duplicates = original rows.
  2. Totals: the cleaned total plus the rejected amounts should explain the raw total where amounts were readable.
  3. Ranges: minimum and maximum dates and amounts are plausible.
Python
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.

Python
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.

Example 1

Data Cleaning and Analysis in a small program

Python
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())
Output
2
20.0
Line-by-line explanation
  1. Line 3to_numeric(..., errors="coerce") converts the text, turning "bad" into NaN instead of raising.
  2. Lines 4 to 5A boolean mask marks the rows that converted successfully; two of the three did.
  3. Line 6Only valid amounts are summed: 12 + 8.
Example 2

A complete cleaning step with a rejection report

Python
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())
Output
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
  1. Lines 15 to 16Repeated donation_id 2 is removed; the two £20 donations on 2 March with different ids both stay.
  2. Line 18Normalising whitespace and case turns Card and Transfer into valid methods.
  3. Lines 23 to 26Each rule appends its reason, so a row failing several checks lists them all.
  4. Line 32The reconciliation line proves every raw row is accounted for.

Common Data Cleaning and Analysis Mistakes

Coercing errors and silently dropping them

Don't
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
df = df.dropna()                       # bad rows disappear without a trace
print(df["amount"].sum())
Do
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

Don't
df = df.drop_duplicates(subset=["date", "amount"])   # deletes genuine repeat donations
Do
df = df.drop_duplicates(subset=["donation_id"])      # same id = same record

Data 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.

Multiple choiceeasy

Why retain rejected row information?

Multiple choicemedium

Which review concern is valid for data cleaning and analysis?

Data Cleaning and Analysis Quiz

2 questions to check what you learned.

  1. Why retain rejected row information?

    • To increase the reported total
    • To explain cleaning decisions
    • Python ignores the distinction
  2. 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