MultiIndex (Hierarchical Indexing)
Reviewed & published by Brayan K
A MultiIndex in pandas is an index with two or more levels — for example labelling each row by a (country, city) pair — which lets a flat two-dimensional DataFrame represent higher-dimensional, hierarchical data and select it with tuple-based lookups.
Part of the free Pandas course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Creating a MultiIndex with set_index on several columns
- Building one directly with from_tuples / from_product
- Selecting with .loc[("outer", "inner")] tuples
- Cross-sections on a single level with .xs
- Sorting the index for correct, fast lookups
- Flattening back with reset_index
1 Creating a MultiIndex
The most common way to get a MultiIndex is set_index(["a", "b"]) — promoting two columns into a two-level row index. You can also build one explicitly with pd.MultiIndex.from_tuples or from_product (every combination of the given levels).
import pandas as pd
df = pd.DataFrame({
"country": ["UK", "UK", "FR", "FR"],
"city": ["London", "Leeds", "Paris", "Lyon"],
"pop_m": [9.0, 0.8, 2.1, 0.5],
})
# Promote two columns into a (country, city) MultiIndex
mi = df.set_index(["country", "city"])
print(mi)
print(mi.index.names) # ['country', 'city']
print("--- from_product ---")
# Every combination of years x quarters
idx = pd.MultiIndex.from_product(
[[2023, 2024], ["Q1", "Q2"]],
names=["year", "quarter"],
)
print(idx)
# (2023, Q1), (2023, Q2), (2024, Q1), (2024, Q2)
# ✅ Expected output:
# pop_m
# country city
# UK London 9.0
# Leeds 0.8
# FR Paris 2.1
# Lyon 0.5
# ['country', 'city']
# --- from_product ---
# MultiIndex([(2023, 'Q1'),
# (2023, 'Q2'),
# (2024, 'Q1'),
# (2024, 'Q2')],
# names=['year', 'quarter'])2 Selecting: .loc Tuples and .xs
Select an exact row with a tuple in .loc. Select everything under an outer label by passing just that label. To filter on an inner level while ignoring the outer one, use .xs (cross-section). Always sort_index() first.
import pandas as pd
df = pd.DataFrame({
"country": ["UK", "UK", "FR", "FR"],
"city": ["London", "Leeds", "Paris", "Lyon"],
"pop_m": [9.0, 0.8, 2.1, 0.5],
}).set_index(["country", "city"]).sort_index() # sort for fast/correct lookups
# Exact row: pass a tuple
print(df.loc[("UK", "London")])
# pop_m 9.0
print("--- all UK rows ---")
# Outer level only -> all cities in the UK
print(df.loc["UK"])
print("--- xs on inner level ---")
# Cross-section: every 'Paris' regardless of country level
print(df.xs("Paris", level="city"))
# ✅ Expected output:
# pop_m 9.0
# Name: (UK, London), dtype: float64
# --- all UK rows ---
# pop_m
# city
# Leeds 0.8
# London 9.0
# --- xs on inner level ---
# pop_m
# country
# FR 2.13 groupby Produces a MultiIndex — and reset_index Flattens It
Grouping by two or more keys returns a result with a MultiIndex. When you want plain columns back — for merging, exporting, or plotting — call reset_index() to flatten the index into ordinary columns.
import pandas as pd
sales = pd.DataFrame({
"region": ["N", "N", "S", "S", "N"],
"product": ["Pen", "Pad", "Pen", "Pad", "Pen"],
"amount": [10, 20, 15, 25, 30],
})
# Grouping by two keys -> a (region, product) MultiIndex
grouped = sales.groupby(["region", "product"])["amount"].sum()
print(grouped)
# region product
# N Pad 20
# Pen 40
# S Pad 25
# Pen 15
print("--- reset_index ---")
# Flatten back to plain columns
flat = grouped.reset_index()
print(flat)
# region product amount
# 0 N Pad 20
# ... (region and product are now regular columns)
# ✅ Expected output:
# region product
# N Pad 20
# Pen 40
# S Pad 25
# Pen 15
# Name: amount, dtype: int64
# --- reset_index ---
# region product amount
# 0 N Pad 20
# 1 N Pen 40
# 2 S Pad 25
# 3 S Pen 15🎯 Your Turn: Grab One Cell
Select the population of Lyon, France. Fill in the tuple you pass to .loc.
import pandas as pd
df = pd.DataFrame({
"country": ["UK", "FR", "FR"],
"city": ["London", "Paris", "Lyon"],
"pop_m": [9.0, 2.1, 0.5],
}).set_index(["country", "city"]).sort_index()
# 🎯 YOUR TURN: select the row for Lyon in France
print(df.loc[___])
# EXPECTED OUTPUT:
# pop_m 0.5
# Name: (FR, Lyon), dtype: float64📋 Quick Reference
| Task | Code |
|---|---|
| Build from columns | df.set_index(["a", "b"]) |
| All combinations | pd.MultiIndex.from_product([...]) |
| Exact row | df.loc[("UK", "London")] |
| Inner level only | df.xs("London", level="city") |
| Sort for speed | df.sort_index() |
| Flatten | df.reset_index() |
🏆 Mini Challenge: Regional Sales Drilldown
Group sales by region and product, sort the index, then drill into one region and one product across all regions.
import pandas as pd
sales = pd.DataFrame({
"region": ["North", "North", "South", "South", "West"],
"product": ["Pen", "Pad", "Pen", "Pad", "Pen"],
"amount": [10, 20, 15, 25, 30],
})
# 1) Build the (region, product) summary and sort it
summary = (sales.groupby(["region", "product"])["amount"]
.sum()
.sort_index())
print(summary)
# 2) Everything in the North region
print("--- North ---")
print(summary.loc["North"])
# 3) Pen sales across ALL regions (inner level via xs)
print("--- Pens everywhere ---")
print(summary.xs("Pen", level="product"))
# EXPECTED: North=10, South=15, West=30
# ✅ Expected output:
# region product
# North Pad 20
# Pen 10
# South Pad 25
# Pen 15
# West Pen 30
# Name: amount, dtype: int64
# --- North ---
# product
# Pad 20
# Pen 10
# Name: amount, dtype: int64
# --- Pens everywhere ---
# region
# North 10
# South 15
# West 30
# Name: amount, dtype: int64❓ Frequently Asked Questions
Lesson complete — hierarchical data holds no fear!
You can create MultiIndexes, select with tuples and cross-sections, sort for correct fast lookups, and flatten back to columns. This unlocks grouped, panel, and time-series data that lives in more than two dimensions.
Practice quiz
What is a MultiIndex?
- A single-level index
- A row or column index with more than one level
- A duplicate column
- A type of join
Answer: A row or column index with more than one level. A MultiIndex labels each row (or column) with two or more levels, like (country, city).
Which call promotes two columns into a MultiIndex?
- df.reset_index()
- df.stack()
- df.set_index(['country','city'])
- df.merge()
Answer: df.set_index(['country','city']). set_index with a list of columns builds a multi-level row index.
What does pd.MultiIndex.from_product([...]) create?
- Every combination of the given levels
- Only matching pairs
- A single tuple
- A flat index
Answer: Every combination of the given levels. from_product yields the Cartesian product — every combination of the levels.
How do you select the exact row for (UK, London)?
- df.loc['UK']['London']
- df.xs('UK')
- df['UK','London']
- df.loc[('UK', 'London')]
Answer: df.loc[('UK', 'London')]. Pass a tuple to .loc to select an exact MultiIndex row.
What does df.xs('London', level='city') do?
- A cross-section on the inner 'city' level
- Drops London
- Sorts by city
- Renames the level
Answer: A cross-section on the inner 'city' level. .xs takes a cross-section on a single level, ignoring the others.
Why should you sort_index() after building a MultiIndex?
- To rename levels
- Partial slicing is reliable and fast only when sorted
- To drop duplicates
- It is never needed
Answer: Partial slicing is reliable and fast only when sorted. An unsorted MultiIndex can raise UnsortedIndexError or a PerformanceWarning on partial slices.
Grouping by two keys (groupby(['region','product'])) produces what?
- A flat index
- A single column
- A scalar
- A result with a MultiIndex
Answer: A result with a MultiIndex. Grouping by multiple keys yields a MultiIndex on the result.
Which method flattens a MultiIndex back into plain columns?
- reset_index()
- set_index()
- stack()
- xs()
Answer: reset_index(). reset_index() turns the index levels back into ordinary columns.
What does df.loc['UK'] return on a (country, city) index?
- Only the first UK row
- An error
- All rows under the UK outer label
- The whole frame
Answer: All rows under the UK outer label. Passing just the outer label selects every row under it.
For sales grouped by (region, product), what does .xs('Pen', level='product') give?
- Pen sales across all regions
- Only the first Pen row
- All products in one region
- A sum of everything
Answer: Pen sales across all regions. The cross-section pulls every Pen entry regardless of region.
Continue this course
- Previous: Reshaping: melt, stack, unstack
- Next: Performance: vectorization & memory — Write fast, memory-efficient Pandas by vectorising and downcasting
- Quick reference: Pandas cheat sheet › Merging & Reshaping
Frequently asked questions
What is a MultiIndex and when does one appear?
A MultiIndex is a row or column index with more than one level — like a (country, city) pair labelling each row. They appear when you set_index on several columns, when groupby groups by multiple keys, and after operations like stack or concat with keys. They let you store higher-dimensional data in a normal 2D DataFrame.
How do I select a single value across MultiIndex levels?
Use a tuple in .loc, for example df.loc[('UK', 'London')]. To select on an inner level while ignoring the outer one, use .xs (cross-section): df.xs('London', level='city'). Slicing across levels also works with pd.IndexSlice.
Why does pandas warn me to sort a MultiIndex?
Lookups and slicing on a MultiIndex are far faster — and partial slicing is only reliable — when the index is lexicographically sorted. Call df.sort_index() once after building a MultiIndex; otherwise pandas may raise a PerformanceWarning or even an error for certain partial selections.