DS 208 · Week 7

pandas II: Grouping, Merging & Reshaping

Summarising by category, joining two tables on a shared key, and pivoting data between long and wide — the moves behind real analysis.

Programming for Data Science · University of the Philippines Cebu

Session Map

Three moves that do the real work

Words for Today

Ten words you will hear this week

group by

Sort the rows into piles, one pile per distinct value of a column.

e.g. df.groupby("region") makes one pile per region

aggregation

A summary that turns many values into one: mean, sum, count, max.

e.g. .agg(["mean", "count"])

split-apply-combine

Split into groups, summarise each group, stack the answers into one small table.

e.g. 34,079 project rows → 18 region rows

key

The column two tables share, used to decide which rows belong together.

e.g. city in both the population and the area table

merge / join

Combine two tables side by side by matching their key values, like Excel's VLOOKUP.

e.g. pd.merge(pops, areas, on="city")

inner / left / outer join

Which rows survive a merge: only matches, every row of the left table, or everything.

e.g. how="left"

concat

Stack tables under (or beside) each other, without matching any key.

e.g. pd.concat([january, february])

long vs wide

Long: one measurement per row. Wide: a grid, with one category spread across the columns.

e.g. city, year, sales vs city, 2024, 2025

pivot / pivot_table

Turn long into wide; pivot_table also summarises repeats, like an Excel PivotTable.

e.g. pivot_table(index="region", columns="year", …)

melt

Turn wide back into long: every cell of the grid becomes its own row.

e.g. wide.melt(id_vars="city")

Where We Left Off

Filtering answers "which rows" — not "per group"

You can keep the Visayan cities. But "the average population of each region" needs a summary per category. That's what grouping is for.

Quick reminders from week 6

DataFrame
a spreadsheet-like table you control with code: rows and named columns
Series
one column on its own: values plus their row labels
index
the row labels down the left edge (0, 1, 2… unless you set them)
NaN
"not a number": pandas' mark for a missing value

Two tables this week

cities: a tiny table (city, region, pop) small enough to check every row by eye. projects: the 34,079 flood-control projects from week 6 (contract_id, region, year, budget, status). Both are called df in the code; the column names tell you which one it is.

One question at a time

Filtering gives you one subset. Grouping gives you every subset at once.

One table isn't enough

Real answers often need two tables joined on a shared column.

Part A

Grouping

Split, apply a summary, combine.

You already know how to take one summary of a whole column, like df["pop"].mean(). This part takes that same summary once per category, all in one line.

The Pattern

Split, apply, combine

Group the rows by a category, apply a summary to each group, then combine those summaries into a small result table. One idea, endlessly reused.

To group by a column means to sort the rows into piles, one pile per distinct value (every "Visayas" row in one pile). An aggregation is a summary that turns many values into one — mean, sum, count, max.

Tip a box of receipts onto a table, sort them into one pile per store (split), add up each pile (apply), and write the totals on one sheet (combine).

Split

Rows fall into buckets by region.

Apply

Each bucket gets a summary — mean, sum, count.

Combine

One row per group, stacked into the answer.

The Mental Model

Five rows in, two summaries out

rows Visayas Luzon Visayas Luzon Visayas split Visayas ×3 Luzon ×2 apply mean → 534k → 812k combine region · mean Luzon · 812k Visayas · 534k
One Line

groupby then a summary

Name the column to group on, the column to summarise, and the summary. pandas returns one value per group, indexed by the group name.

It is the spreadsheet PivotTable with "region" dragged to Rows and "Average of pop" dragged to Values — written as one line you can re-run.

df.groupby("region")["pop"].mean() # region # Luzon 812_000 # Mindanao 690_000 # Visayas 534_000
  • df.groupby("region")split the cities table into one pile per region
  • ["pop"]in each pile, look only at the population column
  • .mean()average each pile's populations
  • the outputa Series: the region names are its index (row labels), each paired with that region's average — e.g. Luzon's cities average 812,000 people
Several Summaries At Once

.agg() for a small report

Pass a list of summaries and get a column for each. One call turns raw rows into a compact per-group report — mean, max, and count together.

.agg() is short for aggregate: "apply these summaries". Give it one name ("mean") or a list of names in square brackets.

df.groupby("region")["pop"].agg( ["mean", "max", "count"]) # region mean max count # Luzon 812k 2.9M 5 # Visayas 534k 964k 7
  • .agg(["mean", "max", "count"])run three summaries on every pile; each one becomes a column
  • the outputa small DataFrame: one row per region, one column per summary. Read across a row: Visayas has 7 cities, the biggest has 964k people, the average is 534k
Many Statistics At Once

One call, a table you can read across

Pass a list of function names and every one is computed per group. Useful as the first thing you look at after any grouping.

Read count alongside the rest

A mean over 12 projects and a mean over 5,412 deserve very different amounts of trust, and only the count tells you which you have.

stats = df.groupby("region").budget.agg([ "count", "sum", "mean", "median", "std", "min", "max"]) stats.shape (18, 7) # 18 regions, 7 stats # sort by whichever column matters stats.sort_values("median", ascending=False)
  • .agg(["count", …, "max"])seven summaries per region: how many, total, average, middle value, spread (std, standard deviation), smallest, largest
  • stats.shape / stats.sort_valuessave the table as stats, then ask its size: (18, 7) = 18 regions by 7 statistics; ascending=False sorts biggest first
On 34,079 Rows

Split, apply, combine — for real

Same three words, a real table. The output has one row per region, and that is the whole point: the grain changed — grain means "what one row stands for". Before: one project per row. After: one region per row.

Name your aggregations

Named aggregation means writing new_name=(column, summary), so you choose the output column names. Without it you can get a MultiIndex: two-level column labels such as ("budget", "sum") that are awkward to use.

by_region = df.groupby("region").agg( projects=("contract_id", "count"), total=("budget", "sum"), typical=("budget", "median"), ) len(df), len(by_region) (34079, 18) # 34,079 projects -> 18 regions
  • df.groupby("region")split the 34,079 project rows into one pile per region
  • .agg(projects=("contract_id", "count"), …)for each pile, make three new columns: how many projects, the total budget, and the typical (median) budget
  • len(df), len(by_region)len counts rows; showing two values side by side gives a pair in brackets
  • (34079, 18)34,079 rows went in, 18 came out: one per region
Total And Typical Disagree

Two summaries, two different winners

RegionProjectsTotal spendMedian project
Region III5,412₱267.7B — highest₱46.3M
National Capital Region3,925₱159.2B₱29.2M
Region V2,814₱155.6B₱46.8M
Central Office180₱43.6B₱144.5M — highest
Two Keys

Grouping by more than one column

Pass a list of column names and you get one row per combination that exists — not per possible combination.

180, not 198

18 regions × 11 years is 198. Eighteen of those pairs never occurred, and groupby does not create them.

g = (df.groupby(["region", "year"]) .budget.sum().reset_index()) len(g) 180 18 * 11 198 # the full grid # 18 region-year pairs have no projects # pivot would show them as NaN
  • groupby(["region", "year"])a list of two columns: one pile per (region, year) pair, e.g. Region III in 2021
  • .budget.sum()add up the budget column in each pile (df.budget is a shortcut for df["budget"])
  • .reset_index()move region and year out of the row labels and back into ordinary columns, so you get a plain table (the outer brackets let the chain continue on the next line)
  • 180 vs 198only 180 pairs actually have projects; pivot (Part C) lays them out as a grid, where the 18 missing pairs show as NaN
as_index=False

Keep the group key as a column

By default the group key becomes the index (the row labels), which is tidy until you want to merge or plot. One argument avoids the reset_index() that usually follows.

reset_index() moves the row labels back into an ordinary column and numbers the rows 0, 1, 2… again.

Or reset afterwards

Same result. Use whichever keeps the chain readable — just be aware which one you did, because .loc behaves differently.

# default: region becomes the INDEX df.groupby("region").budget.sum() a Series, indexed by region # as_index=False: region stays a COLUMN df.groupby("region", as_index=False).budget.sum() a DataFrame: columns = [region, budget] index = 0, 1, 2, ...
The One You Have Not Met

transform

Aggregate without collapsing — so every row can be compared to its own group.

You now know groupby + a summary gives one row per group. transform computes the same per-group summary but copies it back onto every original row.

agg Collapses, transform Does Not

Same calculation, different shape back

agg gives you one row per group. transform gives you one row per original row, filled with that row’s group value.

That is what makes comparison possible

To ask "is this project big for its region?" you need the region median sitting next to every project.

df.groupby("region").budget.median() 18 rows # one per region df.groupby("region").budget.transform("median") 34,079 rows # one per PROJECT # each row gets ITS region median
  • .budget.median()an aggregation: one median per region, so 18 values come back
  • .budget.transform("median")the same 18 medians, but each project row receives its own region's median, so 34,079 values come back

agg writes one line per class on the report card; transform writes the class average next to every student's own mark.

Now Compare

Every project against its own region

Two lines turn an absolute number into a relative one — and relative is usually the interesting question.

The first project in the frame

₱279.8M in Region V, where the median is ₱46.8M. That is 5.98× a typical project there — a fact no single-column summary could give you.

df["reg_median"] = (df.groupby("region") .budget.transform("median")) df["vs_region"] = df.budget / df.reg_median df[["budget", "reg_median", "vs_region"]].iloc[0] budget 279,839,557 reg_median 46,771,198 vs_region 5.98 # outsized for its region
  • df["reg_median"] = …create a new column holding each project's region median (the brackets spread the chain over two lines)
  • df.budget / df.reg_mediandivide row by row: how many "typical projects" is this one worth?
  • .iloc[0]show the first row by position (week 6)
  • vs_region 5.98this project costs almost six times a typical project in its region
A Sanity Check That Proves It Worked

17,036 above, 17,036 below — exactly

filter

Keep whole groups, not rows

Different from a boolean mask (week 6: a True/False per row that picks rows): here the test runs per group, and either the whole group stays or the whole group goes.

A lambda is a tiny function written in one line, with no name. lambda g: len(g) > 3000 means "given a group g, answer True if it has more than 3,000 rows".

Use it for "only groups with enough data"

Comparing regions with 12 projects against ones with 5,000 is rarely fair; this drops the thin ones in one line.

big = df.groupby("region").filter( lambda g: len(g) > 3000) big.region.nunique(), len(big) (4, 16291) sorted(big.region.unique()) ['National Capital Region', 'Region I', 'Region III', 'Region IV-A']
  • .filter(lambda g: …)ask the lambda about each region's pile; keep every row of the piles that answer True
  • (4, 16291)4 regions survived (nunique = number of distinct values), holding 16,291 of the projects
  • sorted(big.region.unique())the names of the regions that were kept, in alphabetical order
rank

Position within a group, not overall

Another transform-shaped tool: it returns one value per row, so you can ask "where does this project sit in its own region?"

method="min" for ties

Two projects at the same budget both get the lower rank, which is what "joint first" means everywhere outside pandas.

A class ranking per section: every student gets "3rd in my section", not "412th in the school".

df["rank_in_region"] = (df.groupby("region") .budget.rank(ascending=False, method="min")) # the biggest project in each region top = df[df.rank_in_region == 1] len(top) 18 # one per region top.nlargest(2, "budget")[["region","budget"]] Central Office 1,447,499,996 Region XI 989,121,700
  • .rank(ascending=False)number each project within its region, biggest budget = 1
  • df[df.rank_in_region == 1]keep only the rank-1 rows: the biggest project in each region (18 rows)
  • .nlargest(2, "budget")of those, the two with the largest budget (week 6)
Cumulative Share

How concentrated is one region’s spending?

Sort descending (biggest first), take a running total, divide by the sum. The result answers "how few projects make up half the money?"

A running total (cumsum, "cumulative sum") adds each value to everything above it: 5, 3, 2 becomes 5, 8, 10.

Region III: a quarter buys half

Its top 1,357 projects — 25.1% of them — account for 50% of its ₱267.7B. Concentrated, but not extreme.

r3 = (df[df.region == "Region III"] .sort_values("budget", ascending=False)) r3["cum_share"] = r3.budget.cumsum() / r3.budget.sum() (r3.cum_share <= .5).sum() + 1 1,357 # of 5,412 projects 1357 / 5412 0.251 # 25.1%
  • r3.budget.cumsum() / r3.budget.sum()running total as a share of the whole: 0.5 means "half the money so far"
  • (r3.cum_share <= .5).sum() + 1count the True values (True counts as 1): how many projects it takes to pass the halfway mark
Quick Check

Tap to reveal

What does df.groupby("region")["pop"].sum() return?

A · one total for the whole column
B · one total per region
C · the original rows, sorted
D · a count of regions

B — one total per region.

Grouping produces one summary row per distinct group value, indexed by that value. A plain df["pop"].sum() would give the single grand total (A).

Your Turn · 8 min

Ask a question only transform can answer

1 · Group properly

Build by_region with projects, total and median. Confirm it has 18 rows.

2 · Find the disagreement

Which region leads on total? Which leads on median? Explain in one sentence why they differ.

3 · Go relative

Use transform to add vs_region. Find the project with the highest ratio — the most outsized project for its area.

4 · Check yourself

Count how many rows are above vs below their region median. If it is not an even split, your transform misaligned.

Quick Check

Tap to reveal

You need each project compared against its own region’s median budget. Which tool?

A · groupby(...).agg("median") — then merge it back
B · groupby(...).transform("median") — it returns one value per original row
C · groupby(...).filter(...)
D · pivot_table with aggfunc="median"
B. transform returns a result aligned to the original index — 34,079 values for 34,079 rows — so it drops straight into a new column. A works but takes an extra merge and a chance to misalign; C keeps or drops whole groups; D changes the grain entirely.
Part B

Merging

Two tables, one shared key, joined into one.

So far every answer came from one table. This part brings in columns from a second table by matching rows on a column both tables share — the code version of Excel's VLOOKUP.

The Motivation

Facts live in different tables

Population sits in one file, land area in another. Both have a city column — the key. Merging lines them up so you can compute density.

New words

key
the column both tables share, used to decide which rows belong together (here city)
merge / join
combine two tables side by side by matching their key values; pandas says merge, databases say join

Excel's VLOOKUP: for each city in sheet 1, look up the same city in sheet 2 and copy its area across. merge does that for every row and every column at once.

Table 1

city, region, pop

Table 2

city, area_km2

Shared key

city — matched row by row.

Line Them Up

merge on the shared column

Name the key with on=. pandas matches rows where the key agrees and glues the columns together into one wider table.

areas = pd.read_csv("areas.csv") both = pd.merge(df, areas, on="city") both["density"] = both["pop"] / both["area_km2"]
  • pd.read_csv("areas.csv")load the second table (city, area_km2) from a file
  • pd.merge(df, areas, on="city")for each row of df, find the row of areas with the same city and glue its columns on
  • both["density"] = …now pop and area sit in the same row, so dividing them gives people per km²
Which Rows Survive?

Four join types, set by how=

how=KeepsUse when
"inner" (default)keys in bothyou need complete matches only
"left"all of the left tableenrich a main table, keep every row
"right"all of the right tablesame idea, other side
"outer"keys in eitheryou want to see what didn't match
See It On Four Cities

Same two tables, three join types, three answers

left table: pops

citypop
Cebu964000
Iloilo457000
Davao1777000

right table: areas

cityarea_km2
Cebu315
Iloilo78
Manila43

how="inner" → 2 rows

citypoparea_km2
Cebu964000315
Iloilo45700078

how="left" → 3 rows

citypoparea_km2
Cebu964000315.0
Iloilo45700078.0
Davao1777000NaN

how="outer" → 4 rows

citypoparea_km2
Cebu964000.0315.0
Davao1777000.0NaN
Iloilo457000.078.0
ManilaNaN43.0
Make The Merge Shout

validate= turns a silent bug into an exception

The worst merge bug is the one that works: a duplicated key quietly doubles your rows and every total after it is wrong. One argument prevents it.

Pick the relationship you expect

many_to_one, one_to_one, one_to_many. If reality disagrees, pandas raises instead of guessing.

many_to_one means "many project rows may share a region, but the lookup table regions lists each region only once". To raise means to stop with an error message (an exception, week 4).

df.merge(regions, on="region", how="left", validate="many_to_one") 34,079 rows — unchanged # if `regions` had a duplicate region: MergeError: Merge keys are not unique in right dataset; not a many-to-one merge # found at the merge, not three # cells later in a wrong total
  • Reading the errorread the last line first: MergeError is the error type (a merge problem), the words after the colon say why
  • keys are not unique in right datasetthe right table (the second one, regions) lists some region twice; check it with regions.region.duplicated().sum()
Find What Did Not Match

indicator=True labels every row

A left join fills unmatched rows with NaN, which looks exactly like missing data. The indicator column tells you which it is.

indicator=True adds a column called _merge that says, for every row, where its key was found: both, left_only or right_only.

Check it immediately

A merge that matched 60% of rows is usually a key-formatting problem — whitespace, case, or a type mismatch between int and str.

m = df.merge(regions, on="region", how="left", indicator=True) m._merge.value_counts() both 34079 left_only 0 right_only 0 # anything in left_only did not match m[m._merge == "left_only"].region.unique()
  • m._merge.value_counts()count how many rows fall in each of the three labels
  • both 34079, left_only 0every project found its region: a clean merge
  • the last lineif anything had failed to match, this lists which region names were the culprits
concat Has An Axis

Rows or columns, and it will not warn you

# axis=0 (default): stack ROWS pd.concat([a, b]) rows: len(a) + len(b) cols: the union, NaN where absent # needs matching COLUMN names
# axis=1: stack COLUMNS pd.concat([a, b], axis=1) rows: aligned on the INDEX cols: len(a.cols) + len(b.cols) # misaligned indexes -> silent NaN
Quick Check

Tap to reveal

A merge you expected to be many-to-one returns 68,158 rows from a 34,079-row left frame. What would have caught this at the moment it happened?

A · how="inner" instead of how="left"
B · validate="many_to_one", which raises MergeError on a duplicated right key
C · Sorting both frames before merging
D · Nothing — you can only find it by inspecting totals afterwards
B. The right frame has a duplicated key, so every left row matched twice. validate= checks the relationship you expect and raises immediately; indicator=True then tells you which rows matched. Without them, the first symptom is a total that is exactly double, three steps downstream.
Break

  Five minutes

Back to turn tables sideways.

Part C

Reshaping

Long for analysis; wide for reading.

You can now summarise and combine tables. This part changes their layout — the same numbers arranged as a long list or as a grid — without changing a single value.

Two Layouts, Same Data

Long is tidy; wide is readable

Long form has one measurement per row — ideal for grouping and plotting. Wide form spreads a category across columns — ideal for a human to scan.

A long table is a receipt roll: one line per sale, however many there are. A wide table is a calendar grid: cities down the side, years across the top, a number in each cell.

Long

city, year, sales — one number per row.

Wide

city, 2023, 2024, 2025 — years as columns.

The Same Four Numbers

pivot folds a list into a grid; melt unfolds it

long: sales (4 rows)

regionyearsales
Visayas202410
Visayas202514
Luzon202422
Luzon202525
pivot_table →
← melt

wide: wide (2 rows × 2 year columns)

region20242025
Luzon2225
Visayas1014
Long → Wide

pivot_table spreads a category out

Pick what labels the rows, what becomes columns, and which values fill the grid. It aggregates on the way, so duplicates collapse cleanly.

To pivot is to turn long into wide: the values of one column (year) become the column headings. pivot_table is exactly Excel's PivotTable — Rows, Columns, Values and "Summarise by".

sales.pivot_table( index="region", columns="year", values="sales", aggfunc="sum")
  • index="region"one row per region (Excel: Rows)
  • columns="year"one column per year (Excel: Columns)
  • values="sales"the numbers that fill the cells (Excel: Values)
  • aggfunc="sum"if several rows land in one cell, add them up (Excel: Summarise by)
pivot_table

pivot, but it can aggregate

pivot fails if a cell would hold more than one value. pivot_table takes an aggfunc (the aggregation to use) and summarises instead.

fill_value for the empty cells

Without it, combinations that never occurred show as NaN — which is honest, but awkward to read in a count table.

pt = df.pivot_table(index="region", columns="status", values="budget", aggfunc="count", fill_value=0) pt status Completed ... On-Going region Central Office 121 ... 50 Region III 4424 ... 822 # excerpt: 2 of 18 rows, 2 of 5 columns pt.shape (18, 5) # 18 regions, 5 statuses
  • aggfunc="count"each cell counts the projects with that region and that status
  • fill_value=0write 0, not NaN, where a combination never happened
  • pt.shapept is the table saved in a variable; (18, 5) means 18 rows by 5 columns
Wide → Long

melt folds columns into rows

The reverse of a pivot. Keep an id column, and gather the rest into a name/value pair — the tidy shape most tools and plots expect.

To melt a table is to turn wide into long: each cell of the grid becomes its own row, labelled with the column heading it came from.

wide.melt( id_vars="city", var_name="year", value_name="sales")
  • id_vars="city"keep city as it is, repeated on every new row
  • var_name="year"the old column headings (2023, 2024, …) go into a new column called year
  • value_name="sales"the numbers from the cells go into a column called sales
melt

Wide back to long, for plotting

Most plotting libraries want long form: one row per observation, with the variable name in a column. melt is the inverse of pivot.

id_vars are the columns to keep

Everything not named there gets folded into the variable/value pair.

wide.reset_index().melt( id_vars="region", var_name="year", value_name="total") region year total 0 Central Office 2016 4.088419e+09 ... # 18 x 11 = 198 rows out, # including the 18 NaN cells
  • wide.reset_index()wide is the region × year grid; reset_index turns its row labels (the regions) back into an ordinary column so melt can keep it
  • the outputone row per (region, year) cell: Central Office spent 4.088419e+09 (about 4.09 billion pesos) in 2016
crosstab

A frequency table of two categories

A shortcut for the count-only pivot: give it two columns and it counts every combination. crosstab is short for "cross-tabulation" — a table of counts with one category down the side and another across the top.

normalize turns counts into rates

normalize="index" makes each row sum to 1, so you compare regions of very different sizes fairly.

pd.crosstab(df.region, df.status).shape (18, 5) # as shares within each region pd.crosstab(df.region, df.status, normalize="index") status Completed ... On-Going region Region III 0.817443 ... 0.151885 # 81.7% of Region III work is done — # comparable across regions now
  • pd.crosstab(df.region, df.status)rows = regions, columns = statuses, each cell = number of projects
  • normalize="index"divide each row by its own total, so each row adds up to 1 (100%)
  • 0.81744381.7% of Region III's projects are Completed (... = columns not shown)
Which Tool

Five jobs, five different answers

You want…UseRows out
One summary per groupgroupby().agg()one per group (18)
Each row vs its groupgroupby().transform()same as input (34,079)
Position within groupgroupby().rank()same as input (34,079)
Keep or drop whole groupsgroupby().filter()a subset of input
Counts of two categoriespd.crosstab()a grid (18×5)
Putting It Together

Densest city per region

Merge to get density, group by region, take the max. Merge, group, summarise — the whole week chained into a real answer.

both = df.merge(areas, on="city") both["density"] = both["pop"] / both["area_km2"] both.groupby("region")["density"].max()
  • df.merge(areas, on="city")Part B: bring each city's area into the cities table (df.merge(areas, …) is the same as pd.merge(df, areas, …))
  • both["density"] = …people per km², one value per city
  • .groupby("region")["density"].max()Part A: the highest density in each region — one number per region
Quick Check

Tap to reveal

You merge two tables and want to keep every row of the left one, matched or not. Which how=?

A · "inner"
B · "left"
C · "right"
D · "outer"

B — "left".

A left join keeps all left rows, filling unmatched right columns with NaN. "inner" would drop the rows that didn't match.

This Week's Lab

Group, merge, and reshape

You'll summarise a dataset by category, join it to a second table on a key, and pivot the result between long and wide to answer set questions. ~45 minutes.

You'll practise

groupby.agg, merge(on=, how=), pivot_table, and melt.

Stretch, if you want

Group by two columns at once: groupby(["region", "year"]).

Recap

Group, merge, reshape

Glossary

Every new word from today, in one line each

Grouping

group by
sort rows into piles, one per distinct value of a column
aggregation
a summary that turns many values into one (mean, sum, count)
split-apply-combine
group, summarise each group, stack the answers into one table
named aggregation
new_name=(column, summary) inside .agg(): you pick the column names
grain
what one row stands for: a project, a region, a region-year
median
the middle value once sorted; "a typical one"
transform
a per-group summary copied back onto every original row
lambda
a tiny one-line function with no name: lambda g: len(g) > 3000
running total (cumsum)
each value plus everything before it: 5, 3, 2 → 5, 8, 10
reset_index()
move row labels back into an ordinary column; rows numbered 0, 1, 2…

Merging & reshaping

key
the shared column used to match rows between two tables
merge / join
combine two tables side by side by matching keys (VLOOKUP)
inner / left / outer join
keep matches only / every left row / every row from both
join type (how=)
the setting that picks inner, left, right or outer
validate= / indicator=
merge safety checks: stop on duplicate keys / label where each row matched
concat
stack tables under or beside each other, no key matching
long vs wide
one measurement per row vs a grid with a category across the columns
pivot / pivot_table
long → wide; pivot_table also summarises repeats (Excel PivotTable)
melt
wide → long: each grid cell becomes a row
crosstab
a table counting every combination of two categories
Before Next Week

Practice & reading

Next Week

Data Visualization with matplotlib & seaborn

Turning a summarised table into a chart that makes the pattern obvious — and the handful of plot types you'll use again and again.

DS 208 · Programming for Data Science