Hierarchical Indexing (MultiIndex)
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)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 labelsprint(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 2print(data.loc[:, 2]) # every row where the inner level equals 2Reshaping with unstack
A hierarchically indexed Series can be pivoted into a DataFrame with unstackunstack —
the inner level becomes columns:
print(data.unstack())print(data.unstack())Missing combinations (like "b""b" + 22, which never existed) become NaNNaN.
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)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_indexreset_index does the reverse — it moves the index levels back into ordinary columns:
print(frame2.reset_index())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 levelprint(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 levellevel argument, so you can aggregate across just one
part of the index without unstacking first:
print(frame2.groupby(level="city").sum())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()sort_index()after building a MultiIndex if you’ll be slicing it a lot. set_indexset_indexdrops the columns you passed by default; passdrop=Falsedrop=Falseif you still want them available as regular columns too.- Selecting an inner level directly (
data.loc[:, 2]data.loc[:, 2]) uses a different syntax than selecting the outer level (data["b"]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
Exercise 2 – Build a MultiIndex With set_index
Exercise 3 – Aggregate by One Index Level
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.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
