Python + AI/ML · Tutorial 4 of 5
Cleaning Data with pandas: missing values and duplicates
By the FUTEAC Team · 3 November 2026 · 14 min read · Beginner
In short
Cleaning data in pandas means three jobs: find the gaps with isna().sum(), find repeated rows with duplicated(), and fix each deliberately — drop exact duplicates, fill a numeric gap with the median, label a missing name "Unknown", set a missing count to 0. The script below cleans a five-row dataset end to end and proves nothing is missing anymore.
- Cleaning is part of analysis, not a warm-up. Missing values and duplicates quietly change totals, averages and model results — a duplicate row is a double-counted customer.
- Look before you fix.
isna().sum()counts the gaps per column;duplicated()flags the repeated rows. Never clean blind. - Fill with a reason, never a guess. Median for a numeric column, a clear label for text, 0 for a count — each choice should be one you could defend to a colleague.
drop_duplicates()does not touch the original. Assign the result to a new variable (and.copy()it before editing) so the raw data stays intact.- This tutorial feeds the AI & Machine Learning: First Steps course (/course/ai-ml-first-steps/) lesson "Cleaning Data: Missing Values and Duplicates" (module "Preparing Data for a Model"), and the Data Cleaning (/skill/data-cleaning/) and NumPy & pandas (/skill/numpy-pandas/) skills.
Before you start
Why clean data before analysing it?
In the Python + AI/ML Beginner roadmap (/roadmap/python-ai-ml-beginner/), Weeks 10–13 are "Data with Python": NumPy and pandas, cleaning and exploring datasets, charts that explain data. Cleaning sits there for a reason — it is the gate between "I have data" and "I can trust what the data says".
Real datasets arrive messy. A shop export has a customer name missing here, a duplicated order row there, a blank cell where the clerk forgot to type. If you average the "age" column without noticing a blank, the blank is silently ignored and your average is wrong. If you count orders without removing a duplicate, you report five orders when there were four. Machine learning models are even less forgiving: most of them refuse to train on data with missing values at all, or silently learn the wrong pattern from them.
So cleaning is not housekeeping you rush through. It is the step where you decide, column by column, what each gap means — and document the decision in code.
The messy dataset we will work with
Everything below runs in any Python environment with pandas installed. Check first:
import pandas as pd
print(pd.__version__) # expected: 2.1.4 (the version this tutorial was tested on)No pandas? pip install pandas in your terminal fixes that in one line.
Our dataset is a tiny shop export — five rows, deliberately broken:
data = {
"name": ["Ali", "Sara", "Ali", "Bilal", None],
"age": [25, 30, 25, None, 28],
"city": ["Lahore", "Karachi", "Lahore", "Islamabad", "Karachi"],
"orders": [3, 5, 3, 2, None],
}
df = pd.DataFrame(data)Read it like a spreadsheet: rows are records (people), columns are facts about them. Two defects are hidden in here — a duplicated row and three missing values. The point of the next two sections is that you *discover* them with code instead of squinting.
How do I find the missing values?
pandas represents a missing value as NaN ("not a number") — even in a text column. isna() asks every cell "are you missing?" and returns a table of True/False; .sum() adds those up per column:
print(df.isna().sum()) # find the gapsname 1
age 1
city 0
orders 1
dtype: int64Three columns have one gap each; city is complete. This one-liner is the report you run at the start of every cleaning session — it tells you *where* the problems are before you touch anything.
Notice what the output teaches you about the column type: there is one gap in age, so pandas stores the whole column as float (25.0, 30.0 …), because NaN is a float value and a column must have one type. That is why Bilal's age prints as 28.0 later, not 28. Not a bug — just how mixed missing data works.
How do I find the duplicate rows?
duplicated() compares every row against every earlier row and flags exact repeats:
print(df.duplicated()) # find the duplicates0 False
1 False
2 True
3 False
4 False
dtype: boolOnly row 2 is flagged — it is an exact copy of row 0 (Ali, 25, Lahore, 3). Row 1 (Sara) and row 3 (Bilal) are one-offs; row 4 looks similar to row 1 but the missing name and missing order count make it a different row, so it is kept. duplicated() keeps the *first* occurrence and flags later copies, which is the behaviour you want: the first row is the original, everything after is the accident.
How do I fix them — drop, or fill?
The rule of thumb: drop rows that add no information, fill values where the row is otherwise useful.
- Duplicates → drop. Row 2 is an exact copy; keeping it would double-count Ali.
drop_duplicates()returns a new DataFrame without it. Assign that to a new variable so the raw data survives — and add.copy()after it, which lets you edit the new frame without pandas warning you that you might be editing a slice of the old one. - Bilal's age (numeric, one gap) → fill with the median. The median of the remaining ages (25, 30, 28) is 28.0 — it is not pulled toward extremes the way an average can be, and it keeps the row usable instead of deleting a real customer.
- The missing name (text) → fill with
"Unknown". Honest: it says "we do not know", not a name you invented. - The missing order count → fill with 0, as an integer. A customer with no recorded orders is treated as zero orders;
.astype(int)restores the column to whole numbers after the fill.
Each decision is one line of code — and each line is the documentation of what you chose.
Worked example: the full cleaning run, start to finish
Here is the complete script, run exactly as written (pandas 2.1.4, Python 3.12.3, November 2026). The outputs after it are the real ones:
import pandas as pd
data = {
"name": ["Ali", "Sara", "Ali", "Bilal", None],
"age": [25, 30, 25, None, 28],
"city": ["Lahore", "Karachi", "Lahore", "Islamabad", "Karachi"],
"orders": [3, 5, 3, 2, None],
}
df = pd.DataFrame(data)
print(df.isna().sum()) # find the gaps
print(df.duplicated()) # find the duplicates
clean = df.drop_duplicates().copy()
clean["age"] = clean["age"].fillna(clean["age"].median())
clean["name"] = clean["name"].fillna("Unknown")
clean["orders"] = clean["orders"].fillna(0).astype(int)
print(clean)
print(clean.isna().sum()) # confirm: nothing missingname 1
age 1
city 0
orders 1
dtype: int64
0 False
1 False
2 True
3 False
4 False
dtype: bool
name age city orders
0 Ali 25.0 Lahore 3
1 Sara 30.0 Karachi 5
3 Bilal 28.0 Islamabad 2
4 Unknown 28.0 Karachi 0
name 0
age 0
city 0
orders 0
dtype: int64Read the last two blocks carefully — they are the proof:
- The cleaned table has four rows, not five: row 2 is gone. Note the index jumps from 1 to 3; pandas keeps the original row labels, which is fine for analysis.
- Bilal's age is now 28.0 — the median of 25, 30 and 28.
- The nameless row 4 is "Unknown", with 0 orders (as an integer, not 0.0).
- The final
isna().sum()shows zero in every column. That last line is the habit to keep: after every cleaning session, re-run the gap report and confirm it reads all zeros.
Try it yourself
1. Run the full script yourself and check your output matches the real one above, line for line — including the dtype: int64 lines. If it matches, you have a working pandas setup. 2. Change the fill strategy and observe the difference: use clean["age"].fillna(clean["age"].mean()) instead of median, and compare Bilal's age. Why is the mean riskier on a column with an extreme value? (Hint: try it with one age changed to 90.) 3. Instead of filling the missing name, drop any row with a missing name using clean.dropna(subset=["name"]). Which row disappears? When would a shop analyst prefer dropping over filling? Write your answer in one sentence.
Continue in the free course: AI & Machine Learning: First Steps →
Common mistakes
- Editing a slice and getting a SettingWithCopyWarning. After
drop_duplicates(), always add.copy()before assigning into the frame. The warning means pandas is not sure which DataFrame you are editing — the copy removes the doubt. - Filling numbers with 0 by default. Zero orders is honest; zero age would be nonsense. Match the fill value to what the column means, not what is convenient.
- Dropping rows with any missing value too eagerly.
df.dropna()on the raw data would delete rows 2, 3 and 4 — three of five rows gone, including real customers. Drop only what you can defend; fill the rest. - Forgetting to re-check after cleaning. The final
isna().sum()is not decoration — run it every time. A fill you typo'd ("Unknown"vs a column name) leaves the gap in place, and only the re-check catches it. - Cleaning the original instead of a copy. If a fill strategy turns out wrong (the median was the wrong choice), you want the raw data untouched to start over.
clean = df.drop_duplicates().copy()keepsdfpristine.
Frequently asked questions
Should I clean data in pandas or in SQL?
Both, at different stages. SQL cleans at the source — dedupe and filter in the database query (the Data pillar's SQL GROUP BY and Aggregations (/tutorials/data/sql-group-by-aggregations) tutorial shows the querying side). pandas cleans at analysis time, when the data is already in Python and you need record-level decisions. They complement each other; the cross-cluster companion piece is the planned tutorial Data Cleaning: a checklist for messy datasets (/tutorials/data/data-cleaning-checklist/).
What is the difference between fillna and dropna?
fillna(value) replaces each missing value with value and keeps the row. dropna() deletes the row entirely. Prefer fillna when the row still carries useful information (Bilal is a real customer); prefer dropna when the row is too incomplete to trust — and only on the columns that matter, via subset=[...].
Why did the age column become float?
One missing value forces the column to float, because pandas stores missing values as NaN, which is a float. The ages print as 25.0 instead of 25. This is normal, not data corruption — and .astype(int) after filling restores whole numbers where that makes sense.
Does drop_duplicates change my original DataFrame?
No — it returns a new DataFrame. Nothing in this tutorial mutates df; the original stays exactly as loaded, which is why the final comparison ("before" vs "after") is always possible.
How does this connect to the next tutorial?
The next tutorial in this series, Train Your First Machine Learning Model with scikit-learn (/tutorials/python/first-machine-learning-model-scikit-learn/) — planned — takes cleaned data for granted: models train on the output of exactly this process. And the course lesson "Cleaning Data: Missing Values and Duplicates" in the "Preparing Data for a Model" module of AI & Machine Learning: First Steps (/course/ai-ml-first-steps/) covers the same ground as part of a full model-building workflow. ---
Follow the Python + AI/ML Beginner Roadmap
Learn Python from zero and take your first steps into AI and machine learning. Free, step by step, at your pace.
View the Roadmap