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

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.1

3 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

TaskCode
Build from columnsdf.set_index(["a", "b"])
All combinationspd.MultiIndex.from_product([...])
Exact rowdf.loc[("UK", "London")]
Inner level onlydf.xs("London", level="city")
Sort for speeddf.sort_index()
Flattendf.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

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.