Skip to main content
Pandas · lesson 8 of 13
Pandas intermediate 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

  1. Using the grouped Series from the first example (grouped by region and product), 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
    1200

    A tuple in .loc[] addresses both levels of the MultiIndex at once, in order, and returns the single matching value.

  2. grouped.xs("East") (no level= argument) — predict what it returns compared to grouped.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.

  3. Add a quarter column to the groupby, so the result is grouped by three levels: region, product, quarter. Then select every Q1 row 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
  4. Take the wide DataFrame from the unstack() example and call .stack() on it. What do you get back, and how does it compare to df before 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: int64

    stack() reverses unstack() — it pivots the product columns back down into a row-index level, reproducing the same (region, product)-indexed values the lesson started unstack() from. The only difference is the missing Name: revenue line — the column name did not survive the round trip.

  5. Reproduce the UnsortedIndexError from 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 UnsortedIndexError regardless 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.

Frequently Asked Questions

When does Pandas create a MultiIndex automatically?
Grouping by more than one column with groupby(["a", "b"]) produces a Series or DataFrame indexed by both columns as a MultiIndex. You can also build one explicitly with set_index() on multiple columns, or pd.MultiIndex.from_tuples().
What is the difference between .loc and .xs on a MultiIndex?
.loc["East"] selects by the outer (first) level only — you can't reach an inner level with it directly without also specifying the outer one. .xs("Widget", level="product") selects by any named level, regardless of its position, and drops that level from the result. Use .xs() when you want to select on an inner level without first filtering the outer one.
Why did df.loc["East":"West"] raise UnsortedIndexError?
Slicing a MultiIndex with .loc requires the index to be lexicographically sorted, so Pandas can find where a range starts and ends without scanning every row. An index built from set_index() or a groupby that didn't produce sorted output isn't automatically sorted. Call df.sort_index() once before slicing to fix it.
What does unstack() actually do?
unstack() pivots one level of a MultiIndex from the rows into the columns, turning a long, hierarchically-indexed Series or DataFrame into a wide one. It's the inverse of stack(), which does the opposite — pivoting columns back down into an index level.