MultiIndex (hierarchical indexing)

pandas · Advanced · 15 min

Multi-key groupbys and pivots return MultiIndexes, so you need to read and tame them even if you never build one.

A MultiIndex labels each row (or column) with a tuple, one value per level, such as (region, store) or (year, month). You usually get one from groupby on several keys, set_index(["a", "b"]), pivot_table with several columns, or agg with several functions per column (which gives MultiIndex columns).

Selecting:

  • df.loc["north"] selects everything under the first-level label, dropping that level.
  • df.loc[("north", "s1")] selects a full key; df.loc[("north", "s1"), "sales"] a cell.
  • df.xs("s1", level="store") takes a cross-section on an inner level.
  • idx = pd.IndexSlice; df.loc[idx[:, ["s1", "s2"]], :] slices inner levels; this requires a lexsorted index, so call sort_index() first (otherwise you get a PerformanceWarning or UnsortedIndexError).

Reshaping: unstack("store") moves a level into the columns, stack() moves it back, swaplevel() and reorder_levels() change order, droplevel(0) removes one.

Aggregating by level: df.groupby(level="region").sum().

Flattening, which you often want before export or plotting:

df.columns = ["_".join(map(str, c)) for c in df.columns]  # ('sales', 'sum') -> 'sales_sum'
df = df.reset_index()                                     # index levels -> columns

Named aggregation (agg(total=("sales", "sum"))) avoids MultiIndex columns in the first place.

MultiIndexes are compact and powerful for panel-like data, but many people find flat tables with ordinary key columns easier to reason about. A good habit: use MultiIndexes transiently (for unstack, level-wise selection) and reset_index() before handing data on.

Example

import pandas as pd

df = pd.DataFrame({
    "region": ["N", "N", "S", "S", "S"],
    "store": ["s1", "s2", "s1", "s3", "s3"],
    "month": [1, 1, 1, 1, 2],
    "sales": [100, 80, 60, 90, 30],
})
g = df.groupby(["region", "store"])["sales"].sum().sort_index()
print(g.index.nlevels, g.loc["S"].to_dict())     # 2 {'s1': 60, 's3': 120}
print(g.loc[("N", "s2")])                        # 80
print(g.xs("s1", level="store").to_dict())       # {'N': 100, 'S': 60}

idx = pd.IndexSlice
print(g.loc[idx[:, ["s1", "s3"]]].tolist())      # [100, 60, 120]
print(g.unstack("store", fill_value=0))
# store   s1  s2   s3
# region
# N      100  80    0
# S       60   0  120

wide = df.groupby("region").agg({"sales": ["sum", "max"]})
print(wide.columns.tolist())                     # [('sales', 'sum'), ('sales', 'max')]
wide.columns = ["_".join(c) for c in wide.columns]
print(wide.reset_index().columns.tolist())       # ['region', 'sales_sum', 'sales_max']

Remember

  • A MultiIndex labels rows or columns with tuples across levels
  • Select with loc tuples, xs(level=...) and pd.IndexSlice
  • Sort the index before slicing inner levels
  • Flatten columns and reset_index() before exporting

Watch out for

  • Slicing an unsorted MultiIndex and hitting UnsortedIndexError or a PerformanceWarning.

Check yourself

  1. `s` has a (country, city) MultiIndex. What does `s.loc['FR']` return?
  2. Which call moves the 'store' level from the row index into the columns?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pandas.pydata.org/docs/user_guide/advanced.html