Pandas · lesson 8 of 13
MultiIndex: Hierarchical Indexing in Pandas
Select, slice, and reshape a Pandas DataFrame indexed by more than one level with .loc and .xs, and fix the UnsortedIndexError almost everyone hits first.
By SkillByExample · Updated
Real-World Scenario
A sales table gets grouped by both region and product to compute total revenue per combination. The result isn’t indexed by one column anymore — it’s indexed by two, nested inside each other. Selecting a single region works the way you’d expect. Selecting a single product, across every region, or slicing a range of regions, does not — until you know which tool to reach for.
Where a MultiIndex Comes From
import pandas as pd
df = pd.DataFrame({
"region": ["East", "East", "West", "West", "East", "West"],
"product": ["Widget", "Gadget", "Widget", "Gadget", "Widget", "Widget"],
"quarter": ["Q1", "Q1", "Q1", "Q1", "Q2", "Q2"],
"revenue": [1000, 1500, 800, 1200, 1100, 900],
})
grouped = df.groupby(["region", "product"])["revenue"].sum()
print(grouped)
print()
print(type(grouped.index))
$ python multiindex1.py
region product
East Gadget 1500
Widget 2100
West Gadget 1200
Widget 1700
Name: revenue, dtype: int64
<class 'pandas.MultiIndex'>
Grouping by two columns doesn’t give you two separate indexes — it gives you one MultiIndex
with two levels, region (level 0) and product (level 1). Every row is addressed by the
pair, not by either value alone.
.loc vs .xs: Selecting a Level
import pandas as pd
df = pd.DataFrame({
"region": ["East", "East", "West", "West", "East", "West"],
"product": ["Widget", "Gadget", "Widget", "Gadget", "Widget", "Widget"],
"revenue": [1000, 1500, 800, 1200, 1100, 900],
})
grouped = df.groupby(["region", "product"])["revenue"].sum()
# .loc reaches the OUTER level directly
print(grouped.loc["East"])
print()
# .xs reaches ANY named level, and drops it from the result
print(grouped.xs("Widget", level="product"))
$ python multiindex2.py
product
Gadget 1500
Widget 2100
Name: revenue, dtype: int64
region
East 2100
West 1700
Name: revenue, dtype: int64
.loc["East"] only works because region is level 0 — it peels off the outermost level and
returns everything under it, still indexed by product. To select Widget across every
region, position doesn’t help: product is level 1, so .xs("Widget", level="product") names
the level explicitly instead of relying on its position.
The Trap: UnsortedIndexError on a Slice
import pandas as pd
df = pd.DataFrame({
"region": ["West", "East", "West", "East"],
"product": ["Widget", "Gadget", "Gadget", "Widget"],
"revenue": [800, 1500, 1200, 1000],
}).set_index(["region", "product"])
try:
print(df.loc["East":"West"])
except pd.errors.UnsortedIndexError as e:
print("UnsortedIndexError:", e)
$ python multiindex3.py
UnsortedIndexError: 'Key length (1) was greater than MultiIndex lexsort depth (0)'
set_index() preserves whatever row order the data already had — it does not sort the index.
.loc["East":"West"] is a range slice, and Pandas can only resolve a range on a MultiIndex by
binary-searching a sorted index. Because this one isn’t sorted, it can’t tell where “East”
starts or “West” ends, and refuses to guess. “Lexsort depth (0)” in the message means Pandas
found zero leading levels in sorted order.
Sorting the index first, then repeating the same slice:
import pandas as pd
df = pd.DataFrame({
"region": ["West", "East", "West", "East"],
"product": ["Widget", "Gadget", "Gadget", "Widget"],
"revenue": [800, 1500, 1200, 1000],
}).set_index(["region", "product"]).sort_index()
print(df.loc["East":"West"])
$ python multiindex4.py
revenue
region product
East Gadget 1500
Widget 1000
West Gadget 1200
Widget 800
One .sort_index() call, and the same slice works — sorting is a one-time cost per DataFrame,
not something to redo before every lookup.
Reshaping with unstack()
unstack() moves one index level out into the columns.
import pandas as pd
df = pd.DataFrame({
"region": ["West", "East", "West", "East"],
"product": ["Widget", "Gadget", "Gadget", "Widget"],
"revenue": [800, 1500, 1200, 1000],
}).set_index(["region", "product"]).sort_index()
wide = df["revenue"].unstack("product")
print(wide)
$ python multiindex5.py
product Gadget Widget
region
East 1500 1000
West 1200 800
unstack("product") pivots the product level out of the row index and into the columns —
the same reshape a spreadsheet pivot table does, going from one row per (region, product) pair
to one row per region with a column per product.
Practice
-
Using the
groupedSeries from the first example (grouped byregionandproduct), select the revenue for("West", "Gadget")directly with a tuple passed to.loc[].Solution
import pandas as pd df = pd.DataFrame({ "region": ["East", "East", "West", "West", "East", "West"], "product": ["Widget", "Gadget", "Widget", "Gadget", "Widget", "Widget"], "revenue": [1000, 1500, 800, 1200, 1100, 900], }) grouped = df.groupby(["region", "product"])["revenue"].sum() print(grouped.loc[("West", "Gadget")])$ python practice_mi1.py 1200A tuple in
.loc[]addresses both levels of the MultiIndex at once, in order, and returns the single matching value. -
grouped.xs("East")(nolevel=argument) — predict what it returns compared togrouped.loc["East"], then run both and compare.Solution
They return the same thing: without
level=,.xs()defaults to level 0, exactly like.loc[]does for a MultiIndex label. The two only diverge once you need to select on an inner level, where only.xs(..., level=...)can name it directly. -
Add a
quartercolumn to the groupby, so the result is grouped by three levels:region,product,quarter. Then select everyQ1row across all regions and products using.xs().Solution
import pandas as pd df = pd.DataFrame({ "region": ["East", "East", "West", "West", "East", "West"], "product": ["Widget", "Gadget", "Widget", "Gadget", "Widget", "Widget"], "quarter": ["Q1", "Q1", "Q1", "Q1", "Q2", "Q2"], "revenue": [1000, 1500, 800, 1200, 1100, 900], }) g3 = df.groupby(["region", "product", "quarter"])["revenue"].sum() print(g3.xs("Q1", level="quarter"))$ python practice_mi2.py region product East Gadget 1500 Widget 1000 West Gadget 1200 Widget 800 Name: revenue, dtype: int64 -
Take the
wideDataFrame from theunstack()example and call.stack()on it. What do you get back, and how does it compare todfbefore the unstack?Solution
import pandas as pd df = pd.DataFrame({ "region": ["West", "East", "West", "East"], "product": ["Widget", "Gadget", "Gadget", "Widget"], "revenue": [800, 1500, 1200, 1000], }).set_index(["region", "product"]).sort_index() wide = df["revenue"].unstack("product") print(wide.stack())$ python practice_mi3.py region product East Gadget 1500 Widget 1000 West Gadget 1200 Widget 800 dtype: int64stack()reversesunstack()— it pivots theproductcolumns back down into a row-index level, reproducing the same (region, product)-indexed values the lesson startedunstack()from. The only difference is the missingName: revenueline — the column name did not survive the round trip. -
Reproduce the
UnsortedIndexErrorfrom this lesson, but instead of calling.sort_index(), fix it by slicing with.loc[("East", "Gadget"):("West", "Widget")]— a full tuple range instead of a single-level range. Does this avoid the error even on the unsorted index? Why might that still be a fragile fix compared to sorting?Solution
import pandas as pd df = pd.DataFrame({ "region": ["West", "East", "West", "East"], "product": ["Widget", "Gadget", "Gadget", "Widget"], "revenue": [800, 1500, 1200, 1000], }).set_index(["region", "product"]) try: print(df.loc[("East", "Gadget"):("West", "Widget")]) except pd.errors.UnsortedIndexError as e: print("UnsortedIndexError:", e)$ python practice_mi4.py UnsortedIndexError: 'Key length (2) was greater than MultiIndex lexsort depth (0)'A full-tuple range slice still requires a sorted index for the same reason — Pandas raises the identical
UnsortedIndexErrorregardless of how many levels the slice bounds specify.sort_index()is the only real fix; there’s no slicing syntax that works around an unsorted MultiIndex.