INDEX MATCH Equivalent in pandas
INDEX/MATCH exists because VLOOKUP cannot look leftwards. pandas has no such limitation, so the pair
collapses into either merge or map depending on how much you need back — and the awkward part of
the Excel version, keeping the two ranges aligned, simply disappears. This guide, part of
Excel Formula Equivalents in pandas,
covers both forms, composite keys, and the approximate-match case that merge_asof handles.
Prerequisites
pip install pandas openpyxl
import pandas as pd
orders = pd.DataFrame({
"Order": [1001, 1002, 1003, 1004, 1005],
"SKU": ["A-100", "B-200", "A-100", "C-300", "D-400"],
"Region": ["North", "South", "West", "North", "South"],
"Units": [12, 4, 9, 22, 7],
})
products = pd.DataFrame({
"SKU": ["A-100", "B-200", "C-300"],
"Description": ["Widget, small", "Gadget", "Widget, large"],
"Unit_Price": [19.99, 49.50, 34.75],
"Category": ["Widgets", "Gadgets", "Widgets"],
})
The one-column form: map
When the lookup returns a single column and the key is a single column, map is the shortest
possible translation and it cannot change the shape of your data.
# =INDEX(products!C:C, MATCH(B2, products!A:A, 0))
prices = products.set_index("SKU")["Unit_Price"]
orders["Unit_Price"] = orders["SKU"].map(prices)
orders["Line_Total"] = orders["Units"] * orders["Unit_Price"]
print(orders)
set_index("SKU")["Unit_Price"] produces a Series indexed by SKU — which is exactly what a lookup
table is. map then translates each SKU into its price, leaving NaN where the SKU is unknown. The
D-400 row demonstrates that: no product record, so no price, and the line total is NaN rather than
a silently wrong number.
map also accepts a plain dictionary, which is often the clearest form when the mapping is short and
belongs in the code rather than in a file:
region_owner = {"North": "Ana", "South": "Ben", "West": "Dev"}
orders["Owner"] = orders["Region"].map(region_owner)
The many-column form: merge
When the lookup should bring back several columns — which in Excel means one INDEX/MATCH per column,
each re-scanning the table — merge does it in one call.
# Three INDEX/MATCH formulas, replaced by one join
enriched = orders.merge(
products[["SKU", "Description", "Unit_Price", "Category"]],
on="SKU",
how="left",
validate="m:1",
)
print(enriched)
how="left" gives VLOOKUP's semantics: every order survives, unmatched ones get NaN.
validate="m:1" is the argument worth adopting as a habit — it asserts that the lookup table has at
most one row per key, and raises immediately if it does not. Without it, a duplicated key in the
lookup table silently multiplies rows, and the total at the bottom of the report is quietly too
large. That failure is described in full in
VLOOKUP Equivalent in pandas for Excel Files.
Reporting what did not match
Excel wraps the formula in IFERROR and shows a blank. That hides the problem one cell at a time; pandas lets you count it once.
unmatched = enriched.loc[enriched["Description"].isna(), ["Order", "SKU"]]
if not unmatched.empty:
print(f"{len(unmatched)} order(s) reference an unknown SKU:")
print(unmatched.to_string(index=False))
An unmatched key is nearly always a data problem — a product retired without updating the catalogue, a typo in an export, a trailing space — and it is worth reporting rather than filling. When a default genuinely is correct, make it explicit rather than incidental:
enriched["Category"] = enriched["Category"].fillna("Uncategorised")
Composite keys
Matching on two columns is where the spreadsheet version gets ugly: Excel needs a helper column
concatenating the keys, or an array formula. merge takes a list.
targets = pd.DataFrame({
"Region": ["North", "North", "South", "South"],
"Category": ["Widgets", "Gadgets", "Widgets", "Gadgets"],
"Target": [40000.0, 15000.0, 22000.0, 30000.0],
})
with_targets = enriched.merge(targets, on=["Region", "Category"], how="left")
print(with_targets[["Order", "Region", "Category", "Target"]])
No helper column, no concatenation, and no risk that "North" + "Widgets" collides with
"NorthWid" + "gets" — which is a real failure mode of the concatenation trick when keys have
variable length.
When the two frames name the same concept differently, left_on and right_on avoid renaming
anything:
merged = orders.merge(
products.rename(columns={"SKU": "Item_Code"}),
left_on="SKU", right_on="Item_Code", how="left",
).drop(columns="Item_Code")
Approximate matches: merge_asof
MATCH with a match type of 1 finds the largest value less than or equal to the lookup — the mechanism
behind every rate table, tax band and volume-discount tier. merge_asof is the direct equivalent and
it handles the sorting requirement explicitly.
discounts = pd.DataFrame({
"Min_Units": [0, 10, 20],
"Discount": [0.0, 0.05, 0.12],
})
priced = pd.merge_asof(
orders.sort_values("Units"),
discounts.sort_values("Min_Units"),
left_on="Units",
right_on="Min_Units",
direction="backward",
)
print(priced[["Order", "Units", "Min_Units", "Discount"]])
Both frames must be sorted on the join key or the result is wrong rather than an error — the same
requirement Excel's approximate MATCH imposes, and the same silent failure when it is not met.
direction="backward" is the default and matches Excel's behaviour; "forward" and "nearest" have
no spreadsheet equivalent at all.
Two-dimensional lookups
The other classic INDEX/MATCH shape uses two MATCH calls — one for the row, one for the column — to pick a value out of a rectangular grid: a rate by region and month, a price by size and finish. That layout is a pivot table stored as a sheet, and the pandas answer is to unpivot it back into rows before joining.
grid = pd.DataFrame({
"Region": ["North", "South", "West"],
"Jan": [0.10, 0.08, 0.06],
"Feb": [0.11, 0.08, 0.07],
"Mar": [0.12, 0.09, 0.07],
})
rates = grid.melt(id_vars="Region", var_name="Month", value_name="Rate")
print(rates.head())
orders["Month"] = ["Jan", "Feb", "Feb", "Mar", "Mar"]
with_rate = orders.merge(rates, on=["Region", "Month"], how="left")
melt turns the wide grid into one row per Region-Month pair, after which the two-dimensional lookup
is an ordinary composite-key join. That reshaping step is worth doing even when a direct lookup would
work, because the long form is the shape every other pandas operation expects — and it survives a new
month being added to the grid, which a formula referencing a fixed column range does not.
Unpivot a Wide Excel Sheet with pandas melt
covers the reshaping in detail.
Keeping the lookup table honest
A lookup is only as reliable as the table behind it, and two checks catch most of what goes wrong.
The first is uniqueness of the key, which validate="m:1" enforces at merge time. The second is
coverage — whether every key in the data has a row in the table — which nothing enforces unless you
ask.
def check_lookup(frame, table, key):
duplicated = table[key].duplicated().sum()
uncovered = sorted(set(frame[key].dropna()) - set(table[key]))
if duplicated:
raise ValueError(f"lookup table has {duplicated} duplicate {key} value(s)")
if uncovered:
print(f"warning: {len(uncovered)} unmatched {key}(s): {uncovered[:10]}")
check_lookup(orders, products, "SKU")
Running that before the merge rather than inspecting NaN afterwards means the message names the problem in the reference data rather than describing its symptom in the output. On a scheduled job it is also the difference between an alert that says "the product catalogue is stale" and a report that quietly shows blanks in the description column.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Row count grows after a merge | Duplicate keys in the lookup table | validate="m:1", and de-duplicate the lookup |
| Everything is NaN | Key dtypes differ — text in one frame, integer in the other | Read both with the same dtype, or cast before merging |
| Some keys match, most do not | Whitespace or case differences | .str.strip().str.casefold() on both sides |
merge_asof gives wrong bands | One or both frames unsorted | Sort both on the join key first |
Columns come back as Price_x and Price_y | Both frames have a column of that name | Select the columns you need before merging, or pass suffixes |
map returns all NaN | The Series index is not the key column | set_index(key)[value] before mapping |
Performance and scale
The performance story is lopsided. Excel's exact MATCH scans the lookup range for every row, so a 50,000-row sheet against a 5,000-row table performs 250 million comparisons in the worst case. pandas builds a hash index of the lookup table once and then resolves each key in constant time.
import time
start = time.perf_counter()
result = orders.merge(products, on="SKU", how="left")
print(f"merge: {time.perf_counter() - start:.4f}s for {len(result):,} rows")
Two habits keep it fast on large data. Select only the columns you need from the lookup table before
merging, so the join does not carry twenty unused columns through the result. And convert repeated
string keys to category dtype when both sides share the same categories — the join then compares
integer codes.
The one case that is genuinely slower in pandas is merge_asof on unsorted data, because the sort
dominates. If the same lookup runs repeatedly, sort the reference table once and keep it sorted.
Conclusion
INDEX/MATCH becomes map for a single column and merge for several, and the leftward-lookup
problem that motivated the formula pair stops existing. Use validate="m:1" so a duplicated key
raises instead of inflating the report, count the unmatched rows rather than hiding them behind a
default, pass a list for composite keys, and reach for merge_asof when the match is approximate.
Frequently asked questions
When should I use map instead of merge? Use map when you want one column added from a lookup table keyed by a single column — it is shorter and cannot duplicate rows. Use merge when you need several columns, a composite key, or control over the join type.
How do I reproduce MATCH on its own? MATCH returns a position, which is rarely what you want in pandas. If you genuinely need the row number, use df.index.get_indexer or a reset_index followed by a merge; usually the position was only ever a means to fetch a value.
What replaces an approximate MATCH with match type 1? pd.merge_asof, which joins each row to the nearest preceding key. It is the right tool for banded lookups — rate tables, tax bands, tier thresholds — and it requires both frames sorted on the key.
Why did my row count go up after a merge? The lookup table has more than one row per key, so each source row matched several. Check with other'Key'.duplicated().any() before merging, and pass validate='m:1' to make pandas raise instead of silently multiplying rows.
How do I handle a lookup that finds nothing? A left merge leaves NaN where Excel would show #N/A. Count them straight afterwards rather than filling them — an unmatched key usually means a data problem worth reporting, not a blank worth hiding.
Related
- Up one level: Excel Formula Equivalents in pandas — the wider function map.
- VLOOKUP Equivalent in pandas for Excel Files — join semantics and the row-multiplication trap in depth.
- Merge Two Excel Files on a Common Column in Python — the same operation across two workbooks.
- Find Rows in One Excel File Missing From Another — reporting the keys that did not match.
- SUMIF and SUMIFS Equivalent in pandas — aggregating once the lookup has enriched the rows.