How-to
Advanced Data Transformation And CleaningDeep dive

Look Up All Matches for a Key Instead of the First

VLOOKUP and XLOOKUP stop at the first match, so a customer with five orders shows one. Return every match with pandas instead: all matching values joined into one cell like TEXTJOIN with FILTER, a count and a total alongside, the nth match, each match in its own column, and the one-to-many merge that keeps a row per match — with the spreadsheet's first-match result shown for comparison.

A customer list needs each customer's order numbers next to their name. VLOOKUP and XLOOKUP both return the first match, so a customer with five orders shows one order number and nothing hints that four are missing. Excel 365 can return all matches with FILTER, usually wrapped in TEXTJOIN to fit them into one cell; older workbooks use helper columns or array formulas. In pandas, "all matches" is a natural operation, but it has several useful shapes: every match joined into one text cell, a count and total next to it, just the second or latest match, each match in its own column, or one output row per match. This page builds each shape, starting from the first-match result a spreadsheet gives, and explains when to use which. It belongs to Merging and Joining Excel DataFrames.

Prerequisites

Bash
pip install pandas openpyxl

crm.xlsx has a Customers sheet with four customers and an Orders sheet with eight orders. Oakridge Farms has four orders, Café Señor and Zenith two each, and Haddad & Sons none — the cases a lookup has to handle. LibreOffice (soffice) recalculates the spreadsheet formulas in Step 1.

Step 1 — What the spreadsheet lookups return

Write a first-match VLOOKUP and an all-matches TEXTJOIN(FILTER(...)) for each customer and recalculate:

Python
import subprocess
from openpyxl import load_workbook

wb = load_workbook("crm.xlsx")
ws = wb["Customers"]
ws["C1"], ws["D1"] = "VLOOKUP (first)", "TEXTJOIN of FILTER (all)"
for r in range(2, ws.max_row + 1):
    ws[f"C{r}"] = f'=IFERROR(INDEX(Orders!$A$2:$A$9,MATCH(A{r},Orders!$B$2:$B$9,0)),"")'
    ws[f"D{r}"] = f'=IFERROR(_xlfn.TEXTJOIN(", ",TRUE,_xlfn._xlws.FILTER(Orders!$A$2:$A$9,Orders!$B$2:$B$9=A{r})),"")'
wb.save("crm_formulas.xlsx")
subprocess.run(["soffice", "--headless", "--convert-to", "xlsx", "--outdir", "calc", "crm_formulas.xlsx"], check=True, capture_output=True)
calc = load_workbook("calc/crm_formulas.xlsx", data_only=True)["Customers"]
excel = {calc[f"A{r}"].value: (calc[f"C{r}"].value, calc[f"D{r}"].value) for r in range(2, calc.max_row + 1)}
for k, v in excel.items():
    print(k, v)

The first-match lookup — written here as INDEX/MATCH, which behaves like VLOOKUP but can return a column to the left — gives SO-101 for Oakridge Farms and nothing more. The TEXTJOIN(FILTER(...)) version lists all four orders. Two file-format details matter when writing these formulas from Python: TEXTJOIN needs the _xlfn. prefix and FILTER the longer _xlfn._xlws. prefix, otherwise the spreadsheet shows #NAME?. Haddad & Sons, with no orders, gets an empty string from the IFERROR wrappers.

Step 2 — All matches in one cell

The pandas version of TEXTJOIN(FILTER(...)) groups the matching table by key and joins the values, then maps the result onto the lookup table:

Python
import pandas as pd

customers = pd.read_excel("crm.xlsx", sheet_name="Customers")
orders = pd.read_excel("crm.xlsx", sheet_name="Orders").sort_values(["Customer", "Date"])

joined = orders.groupby("Customer")["Order"].agg(", ".join)
customers["Orders"] = customers["Customer"].map(joined).fillna("")
print(customers.to_string(index=False))
print("matches spreadsheet:", all(customers.set_index("Customer")["Orders"][k] == (v[1] or "") for k, v in excel.items()))

groupby(...).agg(", ".join) concatenates every order number per customer into one string; map looks each customer up in that result, and fillna("") gives customers without orders an empty cell, like the IFERROR in the formula. (When the recalculated workbook is read back, that empty string arrives as None — openpyxl does not store empty strings — so the comparison treats the two as equal.) Sorting by date first means the joined list is in date order, which a reader expects — the spreadsheet's FILTER returns matches in sheet order, which happens to be the same here. This shape is good for a readable report column; it is a poor shape for further analysis, because the values are now text.

First match versus all matches Oakridge Farms has four orders. VLOOKUP or INDEX/MATCH returns only the first, SO-101. Joining all matches returns SO-101, SO-103, SO-105, SO-108. A customer with no orders gets an empty cell either way. A B 1 Order Customer 2 SO-101 C001 3 SO-103 C001 4 SO-105 C001 5 SO-108 C001 method result VLOOKUP SO-101 all matches SO-101, SO-103, SO-105, SO-108 orders lookup result groupby + join

Step 3 — Counts, totals and the latest match

Often what is really wanted is not the list itself but facts about it: how many matches, their total, the most recent one. Named aggregation produces them all in one pass:

Python
summary = orders.groupby("Customer").agg(
    order_count=("Order", "size"),
    total=("Amount", "sum"),
    first_order=("Date", "min"),
    latest_order=("Order", "last"),
)
customers = customers.merge(summary, on="Customer", how="left")
customers["order_count"] = customers["order_count"].fillna(0).astype(int)
print(customers.drop(columns="Name").to_string(index=False))

Each customer row now carries its number of orders — four for Oakridge Farms, totalling 3,230 — their total value, the date of the first one and the latest order number (the last after sorting by date). A left merge keeps every customer, and customers without orders get zero orders and missing values elsewhere — the honest result, rather than a zero total that could be confused with orders worth nothing. In a spreadsheet this would be COUNTIFS, SUMIFS, MINIFS and an XLOOKUP searching from the end; see AVERAGEIFS, MAXIFS and MINIFS Equivalents in pandas.

Step 4 — The nth match, or each match in its own column

To pick the second match, or to spread matches across columns, number the matches within each key with cumcount:

Python
orders["n"] = orders.groupby("Customer").cumcount() + 1
second = orders.loc[orders["n"] == 2].set_index("Customer")["Order"]
print("second order:", dict(zip(customers["Customer"], customers["Customer"].map(second))))

wide = orders.pivot(index="Customer", columns="n", values="Order").add_prefix("Order ")
print(customers[["Customer"]].merge(wide, left_on="Customer", right_index=True, how="left").to_string(index=False))

cumcount() + 1 numbers each customer's orders 1, 2, 3 … in date order, so "the second match" is a filter on n == 2 — the job people use INDEX with SMALL(IF(...)) array formulas for in older Excel. Pivoting on that number puts each match in its own column — Order 1, Order 2, … — which suits a fixed-width report but becomes unwieldy when one key has dozens of matches. Numbering rows within groups is covered in Number Rows Within Each Group in pandas.

Step 5 — One row per match: the one-to-many merge

When the goal is analysis rather than a display column, keep one row per match. A plain merge does exactly that:

Python
detail = customers[["Customer", "Name"]].merge(orders[["Customer", "Order", "Date", "Amount"]], on="Customer", how="left")
print(detail.to_string(index=False))
print("rows:", len(detail), "| customers:", detail["Customer"].nunique())

The merge repeats each customer once per order, keeping every match as its own row with all its columns — nine rows here: eight orders plus Haddad & Sons with empty order fields, thanks to how="left". This is the shape for pivots, filters and charts. It is also the one that surprises people who expected a lookup: the row count grows. That growth is correct for a one-to-many relationship, but when a lookup table is supposed to have one row per key, an unexpected increase means duplicates — which is what validate="many_to_one" catches, as described in Validate a Merge to Avoid Duplicate Rows.

Five shapes for 'all matches' Joined text in one cell suits a report column. Counts and totals suit a summary. The nth match picks a single value. One column per match suits a fixed-width layout. One row per match, from a merge, suits analysis and pivots. pandas best for joined in one cell groupby.agg(', '.join) report column counts and totals groupby.agg(size, sum…) summaries nth match cumcount + filter second, latest one column per match cumcount + pivot fixed layouts one row per match merge (one-to-many) analysis pivots

Which shape to use

The shape follows from the question. If a person will read the result next to each key, join the matches into text — but keep the list short, or show a count with the first few. If the question is "how many" or "how much", aggregate and never build the text at all. If one specific match matters — the first order, the latest invoice, the second contact — number the matches and filter. And if anything will be calculated, filtered or pivoted afterwards, keep one row per match and summarise at the end. Building the joined text early and then trying to analyse it is the most common way these workflows go wrong: splitting "SO-101, SO-103" back apart to count it undoes work that groupby would have done directly.

Choosing the shape of an all-matches lookup If a person reads the matches next to each key, join them into one cell. If the question is how many or how much, aggregate counts and totals. If one particular match matters, number the matches and filter. If the result will be analysed further, keep one row per match. What will happen to the result? read next to each key join into one cell SO-101, SO-103, … how many / how much aggregate count, total, latest one particular match cumcount + filter second, latest further analysis one row per match left merge

Common pitfalls

  • Trusting a first-match lookup. It hides every match after the first; check how many matches exist.
  • Joined text used for analysis. Keep a row-per-match table for anything you calculate.
  • Unsorted matches. Sort before joining or numbering so "first" and "latest" mean something.
  • Customers with no matches dropped. Use map or a left merge so they stay, with explicit blanks.
  • Missing _xlfn prefixes. TEXTJOIN and FILTER written from Python need them.

Conclusion

A first-match lookup is wrong whenever a key can match more than once. In pandas, choose the shape that fits the job: join all matches into one cell with groupby().agg(", ".join) and map for a readable report column, aggregate counts and totals per key for a summary, number matches with cumcount to pick the nth or spread them into columns, and keep one row per match with a left merge for analysis. Sort first so the order of matches is meaningful, and keep keys without matches visible as blanks.

Frequently asked questions

How do I return all matches instead of the first like VLOOKUP does? Group the matching table by the key, aggregate the values, for example with ", ".join, and map the result onto the lookup table. In Excel 365, TEXTJOIN with FILTER does the same.

How do I get the second match for a key in pandas? Sort the data, number rows within each key with groupby().cumcount() + 1, and filter on number 2.

Why does my merge return more rows than my lookup table? The key matches several rows in the other table, so the merge returns one row per match. That is correct for one-to-many data; use validate to catch it when keys should be unique.

How do I count matches per key? Use groupby on the key with size or count, then map or merge the result onto the lookup table, filling keys with no matches with 0.

More on joins, lookups & comparisons

More on joins, lookups & comparisons →