How-to
Advanced Data Transformation And CleaningDeep dive

TEXTJOIN Equivalent in pandas (Join Values per Group)

Combine many rows into one cell — every product a customer bought, "North, South, East" — the way Excel's TEXTJOIN does. Join per group with groupby and str.join, skip blanks like ignore_empty, convert numbers, de-duplicate and sort within each group, respect Excel's cell length limit, and split the result back into rows.

Excel's TEXTJOIN(", ", TRUE, B2:B20) turns a range into one string with a delimiter between the values, and combined with FILTER it is how people build "all products this customer bought" in a single cell. It is also one of the operations pandas does most naturally: joining strings is plain Python, and doing it per group is one groupby. The details that make it work on real data are the ones TEXTJOIN's ignore_empty argument hints at — blanks, numbers, duplicates, ordering — and one Excel limit that long lists run into. This page, part of Excel Formula Equivalents in pandas, covers each.

Order lines joined into one row per customer Left: order lines with a customer and a product, including a missing product for Contoso and an empty one for Fabrikam. Right: one row per customer with the products joined by commas: Northwind has Gadgets, Widgets; Contoso has Gizmos; Fabrikam has Widgets. Blanks are skipped and repeats removed. A B 1 customer product 2 Northwind Widgets 3 Northwind Gadgets 4 Northwind Widgets 5 Contoso Gizmos 6 Contoso (blank) A B 1 customer products 2 Northwind Gadgets, Widgets 3 Contoso Gizmos 4 Fabrikam Widgets order lines one row per customer groupby + join

Prerequisites

Bash
pip install pandas openpyxl

orders.xlsx has seven order lines for three customers. Northwind bought Widgets twice; Contoso has one line with no product; Fabrikam has one with an empty string.

Step 1 — Join a whole column

For a single list, TEXTJOIN(", ", TRUE, range) is Python's str.join over the non-empty values:

Python
import pandas as pd

orders = pd.read_excel("orders.xlsx", keep_default_na=False, na_values=[""])
products = orders["product"].dropna()
print(", ".join(products))

dropna does the job of ignore_empty=TRUE. It is not optional in pandas: a missing value is NaN, which is a float, and str.join raises TypeError the moment it meets one. Reading with na_values=[""] makes the empty-string cell missing too, so both kinds of blank are skipped the same way.

Step 2 — Join per group

The everyday version — one row per customer with everything they bought — is a groupby with ", ".join as the aggregation:

Python
per_customer = (orders.dropna(subset=["product"])
                      .groupby("customer", sort=False)["product"]
                      .agg(", ".join)
                      .reset_index(name="products"))
print(per_customer.to_string(index=False))

sort=False keeps customers in the order they first appear, like a UNIQUE list in the sheet. Northwind's value lists Widgets twice, because it bought them twice — correct if the column is meant to show every line, redundant if it is meant to show which products. Step 4 handles that. Count Distinct Values per Group in pandas gives the count that often sits next to such a list.

Step 3 — Numbers must become text first

Joining the order numbers fails, because str.join only accepts strings:

Python
try:
    orders.groupby("customer")["order_no"].agg(", ".join)
except TypeError as exc:
    print("TypeError:", exc)

order_lists = orders.groupby("customer", sort=False)["order_no"].agg(lambda s: ", ".join(s.astype(str)))
print(order_lists.to_dict())

Convert inside the aggregation with astype(str), or format the numbers first when their appearance matters — s.map("{:,.2f}".format) for amounts, s.dt.strftime("%d %b") for dates. TEXTJOIN converts numbers silently using their displayed format; pandas makes you choose, which avoids surprises like amounts joined with fifteen decimal places. Order numbers are a good case for plain astype(str): they are identifiers, and any thousands separator would make them harder to search for in another system.

TEXTJOIN arguments and their pandas equivalents A translation table. The delimiter becomes the string whose join method is called. ignore_empty TRUE becomes dropping missing values and empty strings first. Numbers in the range become astype str or a format before joining. Unique values become drop_duplicates or unique in the aggregation. A sorted list becomes sorted inside the aggregation. TEXTJOIN pandas Delimiter ", " ", ".join(...) Skip blanks ignore_empty = TRUE dropna, filter out "" Numbers converted as displayed astype(str) or a format Unique values only UNIQUE(...) inside drop_duplicates in agg Sorted list SORT(...) inside sorted(...) in agg

Step 4 — Unique, sorted lists

"Which products has each customer bought?" wants each product once, usually in alphabetical order — TEXTJOIN(", ", TRUE, SORT(UNIQUE(FILTER(...)))) in Excel. In pandas, do both inside the aggregation function:

Python
def join_unique(values, sep=", "):
    seen = values.dropna().loc[lambda s: s.str.strip() != ""]
    return sep.join(sorted(seen.unique(), key=str.lower))

bought = orders.groupby("customer", sort=False)["product"].agg(join_unique).reset_index(name="products")
print(bought.to_string(index=False))

Northwind's list is now Gadgets, Widgets. Sorting with key=str.lower ignores case, the way Excel's SORT does; plain sorted would put capitalised names first. If the order of first purchase matters more than the alphabet, drop the sorted and keep unique()'s order of appearance.

Show counts, or put each value on its own line

Two variations make joined lists more useful in reports. The first adds a count to each item — "Widgets (2), Gadgets (1)" — which TEXTJOIN cannot do without helper columns. value_counts inside the aggregation produces it directly, most frequent first:

Python
def join_counts(values, sep=", "):
    counts = values.dropna().loc[lambda s: s.str.strip() != ""].value_counts()
    return sep.join(f"{item} ({n})" for item, n in counts.items())

print(orders.groupby("customer", sort=False)["product"].agg(join_counts).to_dict())

The second puts each value on its own line inside one cell — TEXTJOIN(CHAR(10), TRUE, ...) in Excel. Join with "\n" and switch on wrapping for the cell, or the line breaks will not show:

Python
from openpyxl import Workbook
from openpyxl.styles import Alignment

wb = Workbook()
ws = wb.active
ws["A1"] = "\n".join(sorted(orders["product"].dropna().loc[lambda s: s != ""].unique()))
ws["A1"].alignment = Alignment(wrap_text=True, vertical="top")
ws.row_dimensions[1].height = 45
wb.save("lines_in_cell.xlsx")
print(repr(ws["A1"].value))

Setting the row height explicitly makes every line visible whichever application opens the file; three lines of default-size text need roughly 45 points. A cell with dozens of lines is a sign the list belongs on its own sheet, one value per row.

Step 5 — Respect Excel's cell length limit

A cell in an Excel workbook holds at most 32,767 characters. A joined list for a large customer — every invoice number for ten years — can exceed it, and even well below the limit a cell with a thousand items is unreadable. Truncate with an explicit note:

Python
def join_limited(values, limit=3, sep=", "):
    items = sorted(values.dropna().astype(str).unique(), key=str.lower)
    shown = sep.join(items[:limit])
    return shown if len(items) <= limit else f"{shown} … and {len(items) - limit} more"

many = pd.Series([f"INV-{n:04d}" for n in range(1, 8)])
print(join_limited(many))

"… and 4 more" tells the reader the list is incomplete, which a silent truncation does not. When the full list matters, write it as rows on a detail sheet and put a link or a count in the summary cell instead.

Step 6 — Split a joined cell back into rows

The reverse operation comes up just as often: a column that already holds "Widgets, Gadgets" needs one row per product. str.split makes lists and explode turns each list element into its own row:

Python
back = bought.assign(product=bought["products"].str.split(", ")).explode("product")
back["product"] = back["product"].str.strip()
print(back[["customer", "product"]].to_string(index=False))

Four rows — Northwind's two products, Contoso's and Fabrikam's one each. Splitting on the exact delimiter used for joining makes the round trip clean; for lists typed by people, split on a pattern such as r"\s*[,;]\s*" to handle both commas and semicolons and stray spaces. Split a Column into Multiple Columns with pandas covers splitting into columns instead of rows.

Joining and splitting as a round trip Four steps. Order lines are grouped by customer. The products are joined into one text cell per customer with a delimiter. For analysis, the text is split on the same delimiter into lists. explode turns each list item back into its own row. Order lines one row per product sold groupby + join one cell per customer str.split same delimiter explode one row per product again

Write the summary

Write the joined lists next to other per-customer figures, and widen the column and turn on wrapping so long lists stay readable:

Python
from openpyxl import load_workbook
from openpyxl.styles import Alignment

summary = bought.merge(orders.groupby("customer").size().rename("lines").reset_index(), on="customer")
summary.to_excel("customers.xlsx", index=False)
wb = load_workbook("customers.xlsx")
ws = wb.active
ws.column_dimensions["B"].width = 40
for (cell,) in ws.iter_rows(min_row=2, min_col=2, max_col=2):
    cell.alignment = Alignment(wrap_text=True, vertical="top")
wb.save("customers.xlsx")
print(pd.read_excel("customers.xlsx").to_string(index=False))

Wrap Text and Align Cells with openpyxl covers the alignment options.

Common pitfalls

  • NaN in the values. str.join raises TypeError; drop missing values first.
  • Numbers in the values. Convert to text, ideally with a deliberate format.
  • Duplicates you did not want. Join unique() values when the list names things rather than lines.
  • Very long cells. Stay well under 32,767 characters; summarise and link to details.
  • Delimiters inside the values. If a product name contains a comma, a comma-joined list cannot be split back reliably; choose a delimiter that never appears in the data, such as " | ", when the text will be parsed again.
  • Joining before filtering. Filter the rows first (a period, a status), then join — a list built from all rows and filtered afterwards still contains the excluded values.

Performance notes

Joining per group with a Python function is fast for thousands of groups and a few hundred thousand rows. For very large data, de-duplicate first — drop_duplicates(["customer", "product"]) — so each group joins only distinct values, and sort once before grouping instead of sorting inside every call. Both changes shrink the work per group without changing the result.

Conclusion

TEXTJOIN in pandas is ", ".join(...) over the non-missing values of a column, and per group it is groupby(...)[col].agg(...) with a join function. Drop missing values and empty strings first, convert numbers to text deliberately, de-duplicate and sort inside the aggregation when the list names things, cap long lists with an explicit "… and N more", and reverse the operation with str.split and explode.

Frequently asked questions

What is the pandas equivalent of TEXTJOIN? For a whole column, ", ".join(values) over the non-missing values. Per group, df.groupby("key")"col".agg(", ".join) after dropping missing values. Both put a delimiter between the values the way TEXTJOIN does.

How do I skip blanks like TEXTJOIN's ignore_empty? Remove missing values with dropna and empty strings with a filter before joining. str.join fails on NaN, which is a float, so missing values must go first in any case.

Why does join fail on my number column? str.join only accepts strings. Convert first with astype(str) — or format the numbers the way you want them to appear — and then join.

Is there a limit on how long the joined text can be? Excel cells hold at most 32,767 characters. For long lists, truncate with a note such as "… and 120 more", or write the values as rows on a separate sheet instead of cramming them into one cell.

More on excel functions in pandas

More on excel functions in pandas →