More than one level on an axis - where it comes from, how to select through it, and when to flatten it away.
Overview
It arrives whether you asked or not
A MultiIndex is an index with more than one level. It appears whenever a pandas operation produces labels that have structure. That happens more often than you might expect, and it surprises people who did not ask for it.
The most common source is group-by with multiple keys. Grouping by city and year gives a result with a two-level index: city on the outer level, year on the inner. The result prints with a hierarchical layout, and selecting from it requires different syntax than selecting from a flat index.
concat with keys adds a level naming the source frame. agg with multiple functions gives multi-level columns. stack and unstack move levels between the row index and the column index. All of these produce a MultiIndex without you writing the word MultiIndex anywhere.
The practical question is usually not how to build one but how to work with one you already have. Selecting a single value requires a tuple. Selecting all rows for one outer-level value uses xs or a cross-section. Selecting by position still works with iloc, which ignores the labels entirely.
The most common fix is to flatten the MultiIndex and go back to a simple frame. reset_index moves index levels back to columns. For multi-level columns, joining the levels into a single string is the usual approach.
Knowing when to keep a MultiIndex and when to flatten it depends on what you are doing next. Plotting and exporting usually want a flat frame. Further aggregation and selection sometimes benefit from the hierarchical structure. The default choice, when you are unsure, is to flatten. You can always re-group later.
This module covers how a MultiIndex is created, how to select from it, how to flatten it, and the methods that move levels between rows and columns.
Almost no one makes a MultiIndex on purpose. It appears as the result of steps you were doing anyway:
groupby(["city", "year"]) gives one level per key.
concat(..., keys=[...]) adds a level naming the source.
stack() moves a column level into the index.
agg with a few statistics per column gives nested columns.
So the hands-on question is rarely "should I build one" and almost always "I have one, now what".
Worth knowing
You rarely build a MultiIndex on purpose — groupby with several keys, concat(keys=...) and stack all produce one.
A tuple addresses the levels in order: s.loc[("pune", 2024)]. xs takes a cross-section on an inner level.
slice(None) is the wildcard, and pd.IndexSlice is its readable spelling. Inner-level selection usually needs a sorted index.
groupby(level="city") collapses one level without rebuilding the group-by.
agg with several statistics gives hierarchical columns — flatten them with a join over the tuples.
Keep it when you will select through it or unstack; reset_index when the result feeds a merge, a file or a chart.
The MultiIndex
More than one level on an axis, and when to flatten it away.
Where it comes from
You rarely build one on purpose - a groupby with two keys hands you one.
example_01.pypandas
import pandas as pd
df = pd.DataFrame({
"city": ["pune", "pune", "delhi", "delhi"],
"year": [2023, 2024, 2023, 2024],
"sales": [10, 20, 30, 40],
})
out = df.groupby(["city", "year"])["sales"].sum()
print(out)
print()
print("type :", type(out.index).__name__)
print("names :", out.index.names)
print("levels:", [list(l) for l in out.index.levels])
print()
print("concat with keys= and unstack/stack produce them too.")
Output
Selecting through the levels
A tuple addresses the levels in order; loc takes it.
example_02.pypandas
import pandas as pd
s = pd.Series([10, 20, 30, 40], index=pd.MultiIndex.from_tuples(
[("pune", 2023), ("pune", 2024), ("delhi", 2023), ("delhi", 2024)],
names=["city", "year"]))
print("one outer key gives a sub-Series:")
print(s.loc["pune"])
print()
print("a tuple addresses both levels:", s.loc[("pune", 2024)])
print()
print("a list of outer keys:")
print(list(s.loc[["pune", "delhi"]]))
print()
print("cross-section on an INNER level needs xs:")
print(s.xs(2024, level="year"))
Output
slice(None) is the wildcard
Because you cannot write a bare colon inside a tuple.
example_03.pypandas
import pandas as pd
s = pd.Series([10, 20, 30, 40], index=pd.MultiIndex.from_tuples(
[("pune", 2023), ("pune", 2024), ("delhi", 2023), ("delhi", 2024)],
names=["city", "year"])).sort_index()
print("every city, one year:")
print(s.loc[(slice(None), 2024), ])
print()
print("pd.IndexSlice is the readable spelling:")
idx = pd.IndexSlice
print(s.loc[idx[:, 2024]])
print()
print("Selection on inner levels usually needs a SORTED index:")
print(" is_monotonic_increasing:", s.index.is_monotonic_increasing)
Output
Aggregating over one level
level= collapses a level without going back to a groupby.
example_04.pypandas
import pandas as pd
s = pd.Series([10, 20, 30, 40], index=pd.MultiIndex.from_tuples(
[("pune", 2023), ("pune", 2024), ("delhi", 2023), ("delhi", 2024)],
names=["city", "year"]))
print("total per city:")
print(s.groupby(level="city").sum())
print()
print("total per year:")
print(s.groupby(level="year").sum())
print()
print("both spellings work; level= is the one that reads clearly")
print("when the keys are already in the index.")
Output
Columns can be hierarchical too
Which is what agg with several statistics gives you.
example_05.pypandas
import pandas as pd
df = pd.DataFrame({
"city": ["pune", "pune", "delhi"],
"sales": [10, 20, 30],
"units": [1, 2, 3],
})
out = df.groupby("city").agg({"sales": ["sum", "mean"], "units": "sum"})
print(out)
print()
print("columns are a MultiIndex:", list(out.columns))
print()
print("select with a tuple:", list(out[("sales", "sum")]))
print()
print("flatten them - the step people forget:")
out.columns = ["_".join(c).strip("_") for c in out.columns]
print(list(out.columns))
Output
When to flatten it away
A MultiIndex is powerful and awkward; reset_index is often the kinder answer.
example_06.pypandas
import pandas as pd
df = pd.DataFrame({
"city": ["pune", "pune", "delhi"],
"year": [2023, 2024, 2023],
"sales": [10, 20, 30],
})
grouped = df.groupby(["city", "year"])["sales"].sum()
print("as a MultiIndex Series:")
print(grouped)
print()
print("reset_index gives an ordinary frame:")
flat = grouped.reset_index()
print(flat)
print()
print("or ask groupby not to make one:")
print(df.groupby(["city", "year"], as_index=False)["sales"].sum())
print()
print("Keep the MultiIndex when you will select through it or unstack.")
print("Flatten it when the result is going into a merge, a file or a chart.")
Output
Selecting
The mental model: a tuple handles the levels in order, the way a nested dict would.
s.loc["pune"] selects on the outer level and returns a sub-object with that level dropped.
s.loc[("pune", 2024)] handles both levels and returns a scalar.
s.loc[["pune", "delhi"]] takes a list of outer keys.
The awkward case is selecting on an inner level while taking all from the outer one. Two ways:
s.xs(2024, level="year") is the readable form for a single cross-section.
s.loc[(slice(None), 2024), ] is the general form. slice(None) is the wildcard, needed because you can't write a bare colon inside a tuple. pd.IndexSlice makes it look more like normal slicing: s.loc[pd.IndexSlice[:, 2024]].
Pick on inner levels often needs a sorted index. sort_index() first if you get a UnsortedIndexError. As a habit after building one.
Aggregating over a level
s.groupby(level="city").sum() merges one level, keeping the others.
That is often what you want when the keys are already in the index. There's no need to reset_index and group by the column again.
sum(level=...) used to exist as a shortcut and was removed. groupby(level=...) is the current spelling.
unstack() moves a level into the columns and is often more handy than aggregating. It turns a two-level Series into a readable two-dimensional table.
Hierarchical columns
df.groupby("city").agg({"sales": ["sum", "mean"]}) gives columns that are tuples: ("sales", "sum").
Selecting needs the tuple: out[("sales", "sum")].
This is where named aggregation earns its place. agg(total=("sales", "sum"), avg=("sales", "mean")) makes flat column names and skips this problem fully.
When you do end up with nested columns, flattening is one line:
out.columns = ["_".join(c).strip("_") for c in out.columns]
Losing that step is a common source of confusion later, when a thing downstream can't find a column called sales because the column is really ("sales", "sum").
When to keep it, when to flatten
Keep it when you're going to select through it, unstack it into a table, or aggregate over its levels. For truly nested data. Country/region/city, or a panel indexed by entity and date. It's the right structure and makes those steps natural.
Flatten it when the result is leaving pandas or going into an step that doesn't care about tree. A merge, a CSV, a plotting call, a model.
reset_index() turns index levels into plain columns. groupby(..., as_index=False) avoids creating one in the first place.
The honest summary is that a MultiIndex is powerful and awkward in roughly equal measure. It rewards you when the tree is real and the steps use it. It costs you an extra concept at each step when it's merely an artefact of how the result was found. Most of the time, in plain analysis code, flattening early is the kinder choice.
Building one deliberately
at times you want a MultiIndex before any group-by:
pd.MultiIndex.from_tuples([...], names=[...]) — from clear pairs.
pd.MultiIndex.from_product([["a","b"], [2023, 2024]]) — each mix. This Is how you build a full frame to reindex sparse data onto.
pd.MultiIndex.from_arrays([...]) — from parallel label arrays.
df.set_index(["city", "year"]) — from existing columns. This Is the usual route.
from_product plus reindex is the standard way to make a sparse panel dense. Build each expected mix, reindex onto it. The missing ones appear as NaN rather than being silently absent.
Sorting matters more here
Pick on inner levels needs a lexsorted index. Without it, pandas raises UnsortedIndexError or performs badly.
The habit worth forming is to call sort_index() at once after building or reshaping a MultiIndexed object. It's cheap, it drops a class of error. It makes label ranges work.
df.index.lexsort_depth reports how many levels are sorted. This Shows why pick works on the first level and fails on the second.
droplevel, swaplevel, reorder_levels
droplevel("year") drops a level fully. Handy after selecting a single value from it leaves a redundant level behind.
swaplevel(0, 1) exchanges two levels, and sort_index() later is almost always needed for the result to be usable.
reorder_levels([...]) handles more than two.
These come up after a groupby made levels in an order that doesn't suit the next step, and they're cheaper than regrouping.
Aggregating and selecting together
Two patterns cover most work with a MultiIndexed result.
Collapse a level: df.groupby(level="city").sum().
Move a level to columns: df.unstack("year"), giving a wide table.
unstack takes a level by name or spot. fill_value for the gaps. Chaining unstack().plot() is the usual route from a two-key group-by to a chart.
stack() reverses it. In recent pandas it has a future_stack=True option that changes some edge-case action around missing values. The default is being migrated. Pinning the action by name is worth doing in code that must keep working.
Should you use one at all
The honest spot: a MultiIndex is the right structure for truly nested data that you'll select through. An not needed complication else.
Signs it's earning its place:
You select by partial key regularly.
You aggregate over one level and keep the others.
You unstack it into tables.
The tree is real — a panel indexed by entity and time, geography at a few levels.
Signs it isn't:
You at once reset_index() after each step that makes one.
You're fighting UnsortedIndexError and slice(None).
The result is heading for a merge, a CSV or a plotting call.
In the second case, as_index=False on the group-by avoids creating it at all. This Is simpler than creating and flattening.
Getting values out
A recurring need is turning a MultiIndexed result into a thing plain code can use.
reset_index() — each level turns a column.
reset_index(level="year") — one level turns a column, the rest stay.
droplevel("year") — discard a level fully.
to_frame() on the index — the labels as a frame, for inspection.
list(df.index) — the labels as tuples.
df.index.get_level_values("city") — one level as a flat array. This Is what you want for filtering or for building a mask without resetting anything.
That last one is under-used: df[df.index.get_level_values("year") == 2024] filters on a level without any slice(None) syntax.
Column MultiIndexes in particular
nested columns cause more day-to-day friction than nested rows. Most code expects flat column names.
They arrive from agg with a list of functions, from pivot_table with a few value columns, and from unstack.
The flattening idiom is worth storing:
df.columns = ["_".join(map(str, c)).strip("_") for c in df.columns]
map(str, ...) matters because levels are often not strings. A year is an int, and join fails on it.
The better answer is often to avoid creating them: named aggregation makes flat names right. Is clearer at the call site as well.
Performance
A MultiIndex is a set of int codes plus level values. It's compact. Often more so than the equal columns. Repeated labels are stored once.
Pick is fast on a lexsorted index and slow else. This Is why sort_index() matters here more than elsewhere.
Group-by on index levels is close to group-by on columns.
The costs are in usability rather than speed: more concepts, more edge cases. Code that other people find harder to follow.
A summary
They arrive from group-by, concat(keys=), stack and agg rather than being built.
A tuple handles the levels. xs takes a cross-section. slice(None) is the wildcard.
sort_index() after building one, always.
groupby(level=...) aggregates over a level.
unstack turns a level into columns and is often what you really wanted.
Flatten nested columns with a join over the tuples, or avoid them with named aggregation.
Keep it when the tree is real and you select through it. reset_index() when it's an artefact.
Where it fits
nested indexes are the price of nested questions. A panel of entities over time, sales by region and city and month, a survey by respondent and question. All of these have a natural nesting. A MultiIndex expresses it right.
The friction comes when the nesting is incidental rather than real. A group-by on two keys makes one whether or not you wanted the structure.
The test is whether you'll use the tree. If the next step selects a level, aggregates over one, or unstacks it into a table, keep it. If the next step is a merge, a plot or a file, flatten it and move on.
Neither choice is permanent. set_index and reset_index are cheap and reversible. The choice can be made per step rather than committed to at the start.
The one thing worth doing always is sort_index() after building one. Almost each confusing MultiIndex error traces back to an unsorted index. The fix is a single call that costs nothing on data of plain size.
Two more things worth knowing
df.index.names gives the level names and is writable. An index built without names can be labelled after the fact. Named levels make each later groupby(level=...), xs and unstack call readable. Unnamed ones force you to count spots.
pd.IndexSlice deserves a mention on its own. Written as idx = pd.IndexSlice, it turns the awkward df.loc[(slice(None), 2024), :] into df.loc[idx[:, 2024], :]. This Is close enough to plain slicing to read at a glance.
Finally, a MultiIndex on the columns and one on the rows can coexist. This Is what pivot_table with lists for both index and columns makes. That is a truly handy shape for a printed report and a truly awkward one to find against. This Is the trade this whole module shows. The structure that displays best is rarely the structure that changes best.
A worked selection
Putting the pick tools together on one object makes the pattern clearer than any of them alone.
Given sales indexed by (city, year):
s.loc["pune"] — one city, all years, with the city level dropped.
s.loc[("pune", 2024)] — a single value.
s.loc[["pune", "goa"]] — two cities.
s.xs(2024, level="year") — one year, all cities.
s.loc[pd.IndexSlice[:, 2024]] — the same, in slice form.
s[s.index.get_level_values("year") == 2024] — the same again, as an plain mask. The form that composes with other rules.
s.groupby(level="city").sum() — totals per city.
s.unstack("year") — a table, cities down, years across.
Six ways to ask about one year. This Is a fair summary of why MultiIndexes feel heavy. The last two are the ones worth reaching for by default. Aggregate a level, or unstack it into a shape that reads.
Questions people ask about the MultiIndex
What is a MultiIndex? It is an index with more than one level, so each row is identified by a combination of labels, such as (country, year). It usually appears after grouping by several columns.
How do I select rows from a MultiIndex? Use df.loc[('UK', 2020)] for an exact key, df.xs(2020, level='year') for one level, or df.loc['UK'] for the outer level.
How do I turn a MultiIndex back into columns? Call reset_index(), or groupby with as_index=False in the first place. Pass level= to reset only some levels.
Why do I get a PerformanceWarning or a slow lookup? Slicing a MultiIndex needs it sorted. Call sort_index() first.
How do I flatten MultiIndex column names? Join the levels: df.columns = ['_'.join(c) for c in df.columns].
Should I use a MultiIndex at all? Often a flat frame with ordinary columns is easier to filter and merge. Keep a MultiIndex when it speeds up lookups or you are reshaping with stack and unstack.
The MultiIndex, in summary
A MultiIndex arrives from steps you were doing anyway. The handy question is whether to keep it.
Keep it when the tree is real and the next step selects a level, aggregates over one, or unstacks it into a table. Flatten it when the result is heading for a merge, a file or a chart.
Sort it as soon as you build it. Most confusing errors here are an unsorted index. Use xs or a mask on get_level_values rather than fighting slice(None). And prefer named aggregation. This Avoids nested columns fully rather than needing them to be flattened later.
Check yourself
0 of 4
Answer without scrolling back up.
Where do most MultiIndexes come from?
The practical question is rarely 'should I build one' but 'I have one, now what'.
How do you select on an inner level while taking everything from the outer?
slice(None) is the wildcard because you cannot write a bare colon inside a tuple. pd.IndexSlice is its readable spelling.
What does `agg({'sales': ['sum','mean']})` do to the columns?
Named aggregation - agg(total=('sales','sum')) - produces flat names and skips the problem entirely.
When should you flatten a MultiIndex with `reset_index`?
Keep it when the hierarchy is real and you will select through it or unstack. Flatten when it is merely an artefact of how the result was computed.
Cheat sheet
The MultiIndex
Build and slice a pandas MultiIndex on rows or columns. Use xs and IndexSlice for clean lookups and see when to call reset_index instead.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.