Section 1 of 17

1 Before you start

Before you begin, take a few minutes to check what you already know and how confident you feel. You will see the same questions again at the end of the module — this helps both you and us measure what you have learned. Click an option for every question and confidence rating, then click Next to continue.

Pre-test

df.sort_values("age") by default returns what?

Pre-test

df_a and df_b have the same columns. What does pd.concat([df_a, df_b], ignore_index=True) return?

Pre-test

df.groupby("clinic")["age"].mean() returns which kind of object?

Pre-test

You write df.apply(lambda row: row["weight_kg"] / row["height_m"]**2, axis=1). What does the lambda receive each call?

Pre-test

df["diagnosis"].str.contains("diabetes") returns what?

Pre-test

df.drop_duplicates(subset="patient_id") returns a DataFrame that keeps which copy of each duplicated patient_id?

Pre-test

Several columns in df contain scattered NaN values. What does plain df.dropna() do by default?

Pre-test

A column of integers contains one missing value. After read_csv, what dtype does pandas give it?

Pre-confidence

I can apply a custom function to every row of a DataFrame with .apply and a lambda, and explain what the function sees each call.

Not at all confident
Fully confident
Pre-confidence

I can clean a text column with the .str accessor — for example, lowercase every entry or filter rows where the text contains a keyword.

Not at all confident
Fully confident
Pre-confidence

I can decide whether to drop or fill missing values for a given column, and write the code for either.

Not at all confident
Fully confident
Section 2 of 17

2 Introduction

In Part I you learned how to get data into a DataFrame and pull out the rows that matter. Part II is the next layer: cleaning and transforming a single DataFrame — sorting it, grouping it, running custom functions on each row, scrubbing text columns, removing duplicate rows, and handling the missing values that real data always has.

This is the second of three parts on pandas. Part III will cover reshaping (wide / long), combining multiple DataFrames, encoding categories for downstream tools, chaining operations into pipelines, and bridging to NumPy.

This part covers five tools. They are the bread-and-butter operations for turning a raw DataFrame into something analysis-ready:

  • Basic data manipulation — sorting, stacking, and grouping with groupby.
  • Row-wise transforms with apply and lambda — run any function over each row or value.
  • Cleaning text columns with the .str accessor split, upper, contains, and friends for string columns.
  • Finding duplicates with duplicated and drop_duplicates — spot and remove repeated rows.
  • Inspecting and handling missing valuesisna, dropna, and fillna, and the patterns for choosing between them.
A quick overview on turning a raw dataframe to something analysis-ready.
A quick overview on turning a raw dataframe to something analysis-ready.

Try every snippet in the Python Scratchpad on the right. By the end of this part you will read a messy patient CSV, drop the duplicates, fill the gaps, group by clinic, and produce a clean per-group summary.

Section 3 of 17

3 Basic data manipulation

Once you can select what you want, the next set of moves transforms it. Four small operations cover most everyday data work: sorting rows into a useful order, stacking two tables to make one longer one, grouping rows by a category to compute per-group summaries, and adding new columns built from existing ones.

Section 3.1 of 17

3.1 Sorting

df.sort_values takes the column to sort by as its first argument. While it sorts in ascending order by default, you can pass ascending=False for a descending sort. To break ties, you can pass a list of column names; pandas will then sort by the first column, followed by the second within each group, and so on.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
 "patient_id": ["P001", "P002", "P003", "P004"],
 "clinic": ["A", "B", "A", "B"],
 "age": [54, 61, 47, 72],
})
by_age = df.sort_values("age", ascending=False)
print(by_age)

sort_values returns a new DataFrame; it does not modify the original. If you want the change to stick, assign it back: df = df.sort_values("age").

Almost every pandas method behaves this way (returning a new object rather than mutating in place).

Calling sort_values without assignment creates a new DataFrame and leaves the original unchanged.
Calling sort_values without assignment creates a new DataFrame and leaves the original unchanged.
Section 3.2 of 17

3.2 Stack with concat

When two DataFrames share the same columns and you want them as one longer table, use pd.concat. This is the right tool for combining month-by-month files into one big table, or pooling two clinics' results.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df_a = pd.DataFrame({"patient_id": ["P001", "P002"], "age": [54, 61]})
df_b = pd.DataFrame({"patient_id": ["P003", "P004"], "age": [47, 72]})
all_patients = pd.concat([df_a, df_b], ignore_index=True)
print(all_patients)

ignore_index=True asks pandas to renumber the result from 0. Without it, each input keeps its original index, so you would get two rows numbered 0, two numbered 1, and so on. Renumbering is almost always what you want.

Two small DataFrames being concatenated row-wise into a single table, then renumbered with a clean index via ignore_index=True.
Two small DataFrames being concatenated row-wise into a single table, then renumbered with a clean index via ignore_index=True.
Section 3.3 of 17

3.3 Grouping

Grouping is the move that turns pandas from a fancy spreadsheet into a real analysis tool.

By using df.groupby("clinic"), you split the DataFrame into separate groups based on each unique value in the "clinic" column. This allows you to apply a summary function to all of those groups simultaneously.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "clinic": ["A", "A", "B", "B", "B"],
    "age": [54, 47, 61, 72, 38],
    "bmi": [27.3, 31.2, 24.1, 22.8, 29.5],
})
print(df.groupby("clinic")["age"].mean())

Reading the chain from left to right, groupby("clinic") splits the rows into one group per clinic, and ["age"] selects the age column inside each group. Then, .mean() collapses each group into a single number, resulting in a Series with one row per clinic. You can replace .mean() with .median(), .sum(), .count(), or .max() because they all work the same way.

The Split-Apply-Combine pattern behind pandas groupby, where rows are partitioned by a key column, a reduction is applied independently to each group, and the per-group results are stitched back together into a single indexed Series.
The Split-Apply-Combine pattern behind pandas groupby, where rows are partitioned by a key column, a reduction is applied independently to each group, and the per-group results are stitched back together into a single indexed Series.
Section 3.4 of 17

3.4 Multiple summaries with .agg

To compute several summaries in one go, chain .agg after groupby and pass a dictionary mapping each column to the function you want. This is how you build a per-group summary table in one expression.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
 "clinic": ["A", "A", "B", "B"],
 "age": [50, 60, 40, 80],
 "bmi": [25.0, 27.0, 23.0, 29.0],
})
print(df.groupby("clinic").agg({"age": "mean", "bmi": "max"}))
Passing a dict to .agg() after groupby applies a chosen summary function to each named column, returning one summary row per group.
Passing a dict to .agg() after groupby applies a chosen summary function to each named column, returning one summary row per group.
Section 3.5 of 17

3.5 Adding new columns

In pandas, you can easily assign a new column using the syntax df["new_column"] = some_expression.

This expression typically performs a calculation on existing columns, which pandas automatically applies across every row without requiring explicit loops.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
 "weight_kg": [72, 80, 65],
 "height_m": [1.75, 1.80, 1.60],
})
df["bmi"] = df["weight_kg"] / (df["height_m"] ** 2)
print(df)
Pandas creates a new column by applying one formula to every row at once
Pandas creates a new column by applying one formula to every row at once
Section 4 of 17

4 Row-wise transforms with apply and lambda

When the operation you want is not a built-in pandas method, .apply lets you pass any function (usually a quick lambda) and runs it element by element.

On a Series, the function sees one value at a time.

On a DataFrame with axis=1, the function sees one row at a time as a Series.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
 "weight_kg": [72, 80, 65],
 "height_m": [1.75, 1.82, 1.68],
})
df["bmi"] = df.apply(lambda row: row["weight_kg"] / row["height_m"] ** 2, axis=1)
print(df)

df["weight_lbs"] = df["weight_kg"].apply(lambda kg: kg * 2.205)
print(df)
pandas .apply runs a function on each value of a Series and on each row of a DataFrame with axis=1.
pandas .apply runs a function on each value of a Series and on each row of a DataFrame with axis=1.
Section 5 of 17

5 Cleaning text columns with the .str accessor

When working with text data in pandas—like patient IDs, names, or diagnoses—you can access a specialized toolkit of string methods by using .str.

Instead of writing a loop, you can apply these transformations to the entire column at once. Here are a few common examples:

  • Standardize casingdf["patient_id"].str.upper() converts every ID to uppercase.
  • Split textdf["name"].str.split(" ") breaks each name into a list of words based on the spaces.
  • Search for keywordsdf["diagnosis"].str.contains("diabetes") checks each row for the word "diabetes" and returns True or False, which is perfect for filtering your data.
Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "patient_id": ["p001", "p002", "p003"],
    "diagnosis": ["Type 2 diabetes", "Hypertension", "Type 1 diabetes"],
})
print(df["patient_id"].str.upper())
print(df[df["diagnosis"].str.contains("diabetes")])
pandas .str enables vectorised text operations (upper, split, contains) across a Series of patient data.
pandas .str enables vectorised text operations (upper, split, contains) across a Series of patient data.
Section 6 of 17

6 Finding duplicates with duplicated and drop_duplicates

Duplicate rows are a common data quality issue, often caused by a patient being entered into a system twice or the same export file being appended multiple times. Pandas provides two key tools to find and remove these duplicates:

  • df.duplicated() – This scans your data and returns a boolean Series (True or False). It marks the first time it sees a row as False, and flags the second and later copies as True.
  • df.drop_duplicates() – This automatically cleans your dataset by keeping only the first copy of a row and dropping all subsequent duplicates.

By default, pandas looks at every column to determine if a row is an exact duplicate. If you only want to check specific identifiers, like ensuring a patient is nott listed twice regardless of other column differences, you can pass the subset argument:

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "patient_id": ["P001", "P002", "P001", "P003"],
    "age": [54, 61, 54, 47],
})
print(df.duplicated())
print(df.drop_duplicates(subset="patient_id"))
Panda’s df.duplicated() flags repeat rows row by row, and how df.drop_duplicates() then keeps only the first occurrence.
Panda’s df.duplicated() flags repeat rows row by row, and how df.drop_duplicates() then keeps only the first occurrence.
Section 7 of 17

7 Inspecting and handling missing values (isna, dropna)

Real data has gaps. A patient skipped a question on the intake form. An instrument failed for one well of a plate. A column you are now extracting did not exist when an old record was filed. Whatever the cause, the result is the same: empty cells in your table. Pandas represents these as NaN (short for "not a number") and gives you specific tools for finding them and deciding what to do about them.

Section 7.1 of 17

7.1 Counting number of missing data

When opening a new dataset, your first step should always be checking for missing data. You can do this by running df.isna().sum(). On its own, df.isna() creates a grid of True (missing) and False (present) values

Adding .sum() forces pandas to count the True values, giving you an instant total of missing entries per column.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd
import numpy as np

df = pd.DataFrame({
 "patient_id": ["P001", "P002", "P003", "P004"],
 "age": [54, np.nan, 47, 72],
 "bmi": [27.3, 24.1, np.nan, np.nan],
})
print(df.isna().sum())

To check a specific variable instead of the entire dataset, isolate it using df["age"].isna().sum(). Making this quick check a habit for every new file helps you catch dataset issues. If you have a column missing 90% of its data, you might want to rethink your data collection or analysis approach.

df.isna().sum() works first across an entire DataFrame and then on a single column
df.isna().sum() works first across an entire DataFrame and then on a single column
Section 7.2 of 17

7.2 Handling missing data

Once you know what is missing, you have two choices:

  • drop the rows with dropna
  • fill them with fillna.

Plain df.dropna() drops every row that has a missing value in any column, which is usually too aggressive, because a row missing a single irrelevant column gets thrown away with the truly bad rows. The subset argument fixes that: it only looks at the columns you list. df.dropna(subset=["age"]) keeps every row that has an age, regardless of what is or is not missing in the other columns. df.dropna(axis=1) drops columns instead of rows; reach for it when you want to throw out a column that is mostly empty.

By default pandas df.dropna() drops every row that has any NaN (usually too aggressive), so pass subset=['col'] to only check specific columns, or use axis=1 to drop columns instead of rows.
By default pandas df.dropna() drops every row that has any NaN (usually too aggressive), so pass subset=['col'] to only check specific columns, or use axis=1 to drop columns instead of rows.
Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd
import numpy as np

df = pd.DataFrame({
 "patient_id": ["P001", "P002", "P003"],
 "age": [54, np.nan, 47],
 "bmi": [27.3, 24.1, np.nan],
})
print(df.dropna())
print(df.dropna(subset=["age"]))

Filling is the alternative when you cannot afford to lose the rows. df.fillna(0) replaces every NaN with 0, which is fine for some kinds of count data but dangerous for things like age. More often you want a column-specific fill: either by calling fillna on just the column you care about (as below), or by passing a dictionary that maps each column to its own fill value.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd
import numpy as np

df = pd.DataFrame({"age": [54, np.nan, 47, 72]})
df["age"] = df["age"].fillna(df["age"].median()) | age
0 54.0
1 54.0
2 47.0
3 72.0

That pattern, replacing missing ages with the median age, is one of the most common imputations in clinical data, and a defensible default. Fancier strategies (mean within a clinic, predicted from other columns) exist, but always document whichever you used. Quietly filling missing values changes the answers your analysis produces.

Imputing a missing value in a pandas Series by replacing NaN with the median of the remaining values.
Imputing a missing value in a pandas Series by replacing NaN with the median of the remaining values.
Section 8 of 17

8 Putting it together

The five tools in this part are the cleaning pass on a real dataset. A typical short script reads a file (from Part I), drops the duplicate rows, fills in the missing values with a sensible default, runs an apply or a .str call to derive a new column, and groups the result for a per-category summary.

Worked example · Clean a messy patient table and summarise by clinic

Work through this example in three stages. You unlock each stage only after the tutor confirms the previous one. Each stage removes more of the scaffolding — by the end you are writing it yourself.

Problem: You have patients.csv with duplicate patient_id rows, missing BMI values, a free-text diagnosis column, and a clinic column. Drop the duplicates, fill missing BMI with the column median, derive has_diabetes from the diagnosis text, and print the mean BMI per clinic.

Stage 1 · Study the solved example
Fully solved solution
import pandas as pd

df = (
    pd.read_csv("patients.csv")
    .drop_duplicates(subset="patient_id")
)
df["bmi"] = df["bmi"].fillna(df["bmi"].median())
df["has_diabetes"] = df["diagnosis"].str.contains("diabetes", case=False)
summary = df.groupby("clinic")["bmi"].mean()
print(summary)
Walk-through
  1. Load the CSV in a chain and immediately drop duplicate patient_id rows, so every later step sees one row per patient.
  2. Fill the missing BMI values with the column median — the standard impute-with-centre move when a few NaNs would otherwise poison the groupby.
  3. Derive has_diabetes with a case-insensitive .str.contains on the free-text diagnosis, then group by clinic and print the mean BMI per group.
Section 9 of 17

9 Check your understanding

You have reached the end of the module. Try the same questions again — your answers here, paired with your pre-test answers, are how we measure what the module taught you. Answer every question and confidence rating, then click Submit and see results to view your score.

Post-test

df.sort_values("age") by default returns what?

Post-test

df_a and df_b have the same columns. What does pd.concat([df_a, df_b], ignore_index=True) return?

Post-test

df.groupby("clinic")["age"].mean() returns which kind of object?

Post-test

You write df.apply(lambda row: row["weight_kg"] / row["height_m"]**2, axis=1). What does the lambda receive each call?

Post-test

df["diagnosis"].str.contains("diabetes") returns what?

Post-test

df.drop_duplicates(subset="patient_id") returns a DataFrame that keeps which copy of each duplicated patient_id?

Post-test

Several columns in df contain scattered NaN values. What does plain df.dropna() do by default?

Post-test

A column of integers contains one missing value. After read_csv, what dtype does pandas give it?

Post-confidence

I can apply a custom function to every row of a DataFrame with .apply and a lambda, and explain what the function sees each call.

Not at all confident
Fully confident
Post-confidence

I can clean a text column with the .str accessor — for example, lowercase every entry or filter rows where the text contains a keyword.

Not at all confident
Fully confident
Post-confidence

I can decide whether to drop or fill missing values for a given column, and write the code for either.

Not at all confident
Fully confident
Section 10 of 17

10 Your results

Here is how your post-test answers compare with your pre-test answers. The pre/post pairing is the most reliable way to see what this module actually taught you.

Your score

Submit the post-test to see your results.

Muddiest point

What is the one thing from this module that is still unclear to you?

Rate this module

Overall, how would you rate this module?

How likely are you to recommend this module to a peer? (0 = not at all, 10 = extremely likely)