Why stack more than one index level
Sometimes one label isn't enough to describe a row — "city" alone doesn't tell you which year; "year" alone doesn't tell you which city. A MultiIndex lets a single axis carry two or more levels at once, so you can keep working with a Series or DataFrame instead of juggling nested dictionaries.
import pandas as pd
import numpy as np
data = pd.Series(
np.arange(9),
index=[
["a", "a", "a", "b", "b", "c", "c", "d", "d"],
[1, 2, 3, 1, 3, 1, 2, 2, 3],
],
)
print(data)The blank-looking gaps in the printed output mean "same as the label directly above" — it's just a display convenience, the index itself has no gaps.
Partial indexing
With a hierarchical index, you can select using just the outer level:
print(data["b"]) # every row where the outer level is "b"
print(data.loc["b":"c"]) # a slice across outer-level labelsYou can also reach into the inner level directly:
print(data.loc[:, 2]) # every row where the inner level equals 2Reshaping with unstack
A hierarchically indexed Series can be pivoted into a DataFrame with unstack —
the inner level becomes columns:
print(data.unstack())Missing combinations (like "b" + 2, which never existed) become NaN.
Building a MultiIndex from DataFrame columns: set_index
More often, you'll have plain columns that you want to promote into a hierarchical row index:
frame = pd.DataFrame({
"city": ["pune", "pune", "delhi", "delhi"],
"year": [2023, 2024, 2023, 2024],
"sales": [100, 120, 200, 220],
})
frame2 = frame.set_index(["city", "year"])
print(frame2)reset_index does the reverse — it moves the index levels back into ordinary columns:
print(frame2.reset_index())Reordering and sorting levels
print(frame2.swaplevel("city", "year")) # levels interchanged, data untouched
print(frame2.sort_index(level="year")) # sort using just one levelAggregating by level
Many summary methods accept a level argument, so you can aggregate across just one
part of the index without unstacking first:
print(frame2.groupby(level="city").sum())Common pitfalls
- Partial indexing performance is much better when the index is sorted starting from
the outermost level — call
sort_index()after building a MultiIndex if you'll be slicing it a lot. set_indexdrops the columns you passed by default; passdrop=Falseif you still want them available as regular columns too.- Selecting an inner level directly (
data.loc[:, 2]) uses a different syntax than selecting the outer level (data["b"]) — easy to mix up at first.
Visualize it
flowchart LR A["Flat columns:
city, year, sales"] -->|"set_index(['city','year'])"| B["MultiIndex rows:
city -> year -> sales"] B -->|"reset_index()"| A B -->|"groupby(level='city')"| C["Aggregated by outer level"]
🧪 Try It Yourself
Exercise 1 – Select From the Outer Level
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Exercise 2 – Build a MultiIndex With set_index
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Exercise 3 – Aggregate by One Index Level
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Check your understanding
For a Series with outer labels a to d and inner labels 1 to 3, what does data.loc[:, 2] select?
The first slot of loc covers the outer level and the second reaches the inner level. In the lesson data, that selects the rows (a, 2), (c, 2) and (d, 2).
Show answer
A — Every row whose inner level equals 2 — The first slot of loc covers the outer level and the second reaches the inner level. In the lesson data, that selects the rows (a, 2), (c, 2) and (d, 2).
What does data.unstack() do to a Series with a two-level index?
unstack pivots the inner level into columns. Combinations that never existed, like b and 2, become NaN.
Show answer
A — Moves the inner level into columns, producing a DataFrame — unstack pivots the inner level into columns. Combinations that never existed, like b and 2, become NaN.
What does frame.set_index(["city", "year"]) do by default?
set_index promotes columns into a hierarchical row index and drops them unless you pass drop=False. reset_index does the reverse.
Show answer
A — Builds a MultiIndex from the two columns and drops them from the regular columns — set_index promotes columns into a hierarchical row index and drops them unless you pass drop=False. reset_index does the reverse.
How do you sum sales for each city when city and year form the index?
groupby(level=...) aggregates across just one part of the index without unstacking first.
Show answer
A — frame2.groupby(level="city").sum() — groupby(level=...) aggregates across just one part of the index without unstacking first.
Next
A MultiIndex is one way to reshape data into a different structure — continue to Reshaping Data (pivot, pivot_table, melt) for more ways to move between wide and long formats.