[{"data":1,"prerenderedAt":2259},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas":2227,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas":2235,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas":2240,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas":2241},{"id":4,"title":5,"body":6,"dateModified":2193,"datePublished":2193,"description":2194,"extension":2195,"faq":2196,"keyword":2205,"kind":2206,"meta":2207,"navigation":387,"path":2216,"seo":2217,"slug":2220,"stem":2221,"topics":2222,"type":2225,"__hash__":2226},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas\u002Findex.md","TEXTJOIN Equivalent in pandas (Join Values per Group)",{"type":7,"value":8,"toc":2177},"minimark",[9,40,306,311,338,344,348,359,465,490,494,504,597,617,621,627,742,763,892,896,903,1028,1052,1056,1066,1181,1192,1371,1374,1378,1381,1563,1566,1570,1585,1664,1676,1796,1800,1803,2013,2020,2024,2075,2079,2086,2090,2108,2112,2122,2128,2134,2140,2144,2173],[10,11,12,13,17,18,21,22,25,26,29,30,33,34,39],"p",{},"Excel's ",[14,15,16],"code",{},"TEXTJOIN(\", \", TRUE, B2:B20)"," turns a range into one string with a delimiter between the values, and combined with ",[14,19,20],{},"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 ",[14,23,24],{},"groupby",". The details that make it work on real data are the ones ",[14,27,28],{},"TEXTJOIN","'s ",[14,31,32],{},"ignore_empty"," argument hints at — blanks, numbers, duplicates, ordering — and one Excel limit that long lists run into. This page, part of ",[35,36,38],"a",{"href":37},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002F","Excel Formula Equivalents in pandas",", covers each.",[41,42,50,51,50,55,50,59,50,67,50,76,50,80,50,87,50,91,50,95,50,99,50,104,50,106,50,110,50,112,50,115,50,118,50,122,50,124,50,128,50,130,50,133,50,136,50,140,50,142,50,144,50,146,50,149,50,152,50,156,50,158,50,160,50,162,50,164,50,167,50,171,50,173,50,176,50,178,50,181,50,184,50,188,50,190,50,192,50,194,50,197,50,200,50,203,50,206,50,210,50,213,50,215,50,218,50,220,50,222,50,224,50,227,50,229,50,231,50,233,50,235,50,240,50,244,50,246,50,248,50,250,50,252,50,254,50,256,50,258,50,260,50,262,50,265,50,267,50,269,50,275,50,279,50,287,50,292,50,300],"svg",{"viewBox":43,"role":44,"ariaLabelledBy":45,"xmlns":48,"style":49},"41 0 677 248","img",[46,47],"tj-group-t","tj-group-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:677px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[52,53,54],"title",{"id":46},"Order lines joined into one row per customer",[56,57,58],"desc",{"id":47},"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.",[60,61],"rect",{"x":62,"y":63,"width":64,"height":65,"fill":66},"41","0","677","248","#ffffff",[60,68],{"x":69,"y":70,"width":71,"height":72,"rx":63,"fill":73,"stroke":74,"style":75},"57.8","40.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[60,77],{"x":78,"y":70,"width":79,"height":72,"rx":63,"fill":73,"stroke":74,"style":75},"89.8","88.8",[81,82,86],"text",{"x":83,"y":84,"style":85},"134.2","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[60,88],{"x":89,"y":70,"width":90,"height":72,"rx":63,"fill":73,"stroke":74,"style":75},"178.7","75.8",[81,92,94],{"x":93,"y":84,"style":85},"216.6","B",[60,96],{"x":69,"y":97,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},"64.0","28.0",[81,100,103],{"x":101,"y":102,"style":85},"73.8","83.0","1",[60,105],{"x":78,"y":97,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,107,109],{"x":83,"y":102,"style":108},"font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","customer",[60,111],{"x":89,"y":97,"width":90,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,113,114],{"x":93,"y":102,"style":108},"product",[60,116],{"x":69,"y":117,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},"92.0",[81,119,121],{"x":101,"y":120,"style":85},"111.0","2",[60,123],{"x":78,"y":117,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,125,127],{"x":83,"y":120,"style":126},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Northwind",[60,129],{"x":89,"y":117,"width":90,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,131,132],{"x":93,"y":120,"style":126},"Widgets",[60,134],{"x":69,"y":135,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},"120.0",[81,137,139],{"x":101,"y":138,"style":85},"139.0","3",[60,141],{"x":78,"y":135,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,143,127],{"x":83,"y":138,"style":126},[60,145],{"x":89,"y":135,"width":90,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,147,148],{"x":93,"y":138,"style":126},"Gadgets",[60,150],{"x":69,"y":151,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},"148.0",[81,153,155],{"x":101,"y":154,"style":85},"167.0","4",[60,157],{"x":78,"y":151,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,159,127],{"x":83,"y":154,"style":126},[60,161],{"x":89,"y":151,"width":90,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,163,132],{"x":93,"y":154,"style":126},[60,165],{"x":69,"y":166,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},"176.0",[81,168,170],{"x":101,"y":169,"style":85},"195.0","5",[60,172],{"x":78,"y":166,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,174,175],{"x":83,"y":169,"style":126},"Contoso",[60,177],{"x":89,"y":166,"width":90,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,179,180],{"x":93,"y":169,"style":126},"Gizmos",[60,182],{"x":69,"y":183,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},"204.0",[81,185,187],{"x":101,"y":186,"style":85},"223.0","6",[60,189],{"x":78,"y":183,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,191,175],{"x":83,"y":186,"style":126},[60,193],{"x":89,"y":183,"width":90,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,195,196],{"x":93,"y":186,"style":126},"(blank)",[60,198],{"x":199,"y":70,"width":71,"height":72,"rx":63,"fill":73,"stroke":74,"style":75},"446.7",[60,201],{"x":202,"y":70,"width":79,"height":72,"rx":63,"fill":73,"stroke":74,"style":75},"478.7",[81,204,86],{"x":205,"y":84,"style":85},"523.1",[60,207],{"x":208,"y":70,"width":209,"height":72,"rx":63,"fill":73,"stroke":74,"style":75},"567.5","134.6",[81,211,94],{"x":212,"y":84,"style":85},"634.9",[60,214],{"x":199,"y":97,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},[81,216,103],{"x":217,"y":102,"style":85},"462.7",[60,219],{"x":202,"y":97,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,221,109],{"x":205,"y":102,"style":108},[60,223],{"x":208,"y":97,"width":209,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,225,226],{"x":212,"y":102,"style":108},"products",[60,228],{"x":199,"y":117,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},[81,230,121],{"x":217,"y":120,"style":85},[60,232],{"x":202,"y":117,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,234,127],{"x":205,"y":120,"style":126},[60,236],{"x":208,"y":117,"width":209,"height":98,"rx":63,"fill":237,"stroke":238,"style":239},"#d9f4f1","var(--teal,#0f9488)","stroke-width:1.5px",[81,241,243],{"x":212,"y":120,"style":242},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Gadgets, Widgets",[60,245],{"x":199,"y":135,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},[81,247,139],{"x":217,"y":138,"style":85},[60,249],{"x":202,"y":135,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,251,175],{"x":205,"y":138,"style":126},[60,253],{"x":208,"y":135,"width":209,"height":98,"rx":63,"fill":237,"stroke":238,"style":239},[81,255,180],{"x":212,"y":138,"style":242},[60,257],{"x":199,"y":151,"width":71,"height":98,"rx":63,"fill":73,"stroke":74,"style":75},[81,259,155],{"x":217,"y":154,"style":85},[60,261],{"x":202,"y":151,"width":79,"height":98,"rx":63,"fill":66,"stroke":74,"style":75},[81,263,264],{"x":205,"y":154,"style":126},"Fabrikam",[60,266],{"x":208,"y":151,"width":209,"height":98,"rx":63,"fill":237,"stroke":238,"style":239},[81,268,132],{"x":212,"y":154,"style":242},[81,270,274],{"x":271,"y":272,"style":273},"156.2","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","order lines",[81,276,278],{"x":277,"y":272,"style":273},"574.4","one row per customer",[280,281],"line",{"x1":282,"y1":283,"x2":284,"y2":283,"stroke":285,"style":286},"264.5","108.0","432.7","var(--brand,#5b5cf0)","stroke-width:2px",[288,289],"polygon",{"points":290,"fill":291},"436.7,108.0 427.7,113.0 427.7,103.0","#5b5cf0",[60,293],{"x":294,"y":295,"width":296,"height":297,"rx":298,"fill":66,"stroke":74,"style":299},"289.7","75.0","121.8","22.0","11","stroke-width:1.2px",[81,301,305],{"x":302,"y":303,"style":304},"350.6","90.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","groupby + join",[307,308,310],"h2",{"id":309},"prerequisites","Prerequisites",[312,313,318],"pre",{"className":314,"code":315,"language":316,"meta":317,"style":317},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[14,319,320],{"__ignoreMap":317},[321,322,324,328,332,335],"span",{"class":280,"line":323},1,[321,325,327],{"class":326},"sMTad","pip",[321,329,331],{"class":330},"srMev"," install",[321,333,334],{"class":330}," pandas",[321,336,337],{"class":330}," openpyxl\n",[10,339,340,343],{},[14,341,342],{},"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.",[307,345,347],{"id":346},"step-1-join-a-whole-column","Step 1 — Join a whole column",[10,349,350,351,354,355,358],{},"For a single list, ",[14,352,353],{},"TEXTJOIN(\", \", TRUE, range)"," is Python's ",[14,356,357],{},"str.join"," over the non-empty values:",[312,360,364],{"className":361,"code":362,"language":363,"meta":317,"style":317},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\norders = pd.read_excel(\"orders.xlsx\", keep_default_na=False, na_values=[\"\"])\nproducts = orders[\"product\"].dropna()\nprint(\", \".join(products))\n","python",[14,365,366,382,389,433,450],{"__ignoreMap":317},[321,367,368,372,376,379],{"class":280,"line":323},[321,369,371],{"class":370},"s-kum","import",[321,373,375],{"class":374},"skGVy"," pandas ",[321,377,378],{"class":370},"as",[321,380,381],{"class":374}," pd\n",[321,383,385],{"class":280,"line":384},2,[321,386,388],{"emptyLinePlaceholder":387},true,"\n",[321,390,392,395,398,401,404,407,411,413,417,419,422,424,427,430],{"class":280,"line":391},3,[321,393,394],{"class":374},"orders ",[321,396,397],{"class":370},"=",[321,399,400],{"class":374}," pd.read_excel(",[321,402,403],{"class":330},"\"orders.xlsx\"",[321,405,406],{"class":374},", ",[321,408,410],{"class":409},"sa561","keep_default_na",[321,412,397],{"class":370},[321,414,416],{"class":415},"sP0c6","False",[321,418,406],{"class":374},[321,420,421],{"class":409},"na_values",[321,423,397],{"class":370},[321,425,426],{"class":374},"[",[321,428,429],{"class":330},"\"\"",[321,431,432],{"class":374},"])\n",[321,434,436,439,441,444,447],{"class":280,"line":435},4,[321,437,438],{"class":374},"products ",[321,440,397],{"class":370},[321,442,443],{"class":374}," orders[",[321,445,446],{"class":330},"\"product\"",[321,448,449],{"class":374},"].dropna()\n",[321,451,453,456,459,462],{"class":280,"line":452},5,[321,454,455],{"class":415},"print",[321,457,458],{"class":374},"(",[321,460,461],{"class":330},"\", \"",[321,463,464],{"class":374},".join(products))\n",[10,466,467,470,471,474,475,478,479,481,482,485,486,489],{},[14,468,469],{},"dropna"," does the job of ",[14,472,473],{},"ignore_empty=TRUE",". It is not optional in pandas: a missing value is ",[14,476,477],{},"NaN",", which is a float, and ",[14,480,357],{}," raises ",[14,483,484],{},"TypeError"," the moment it meets one. Reading with ",[14,487,488],{},"na_values=[\"\"]"," makes the empty-string cell missing too, so both kinds of blank are skipped the same way.",[307,491,493],{"id":492},"step-2-join-per-group","Step 2 — Join per group",[10,495,496,497,499,500,503],{},"The everyday version — one row per customer with everything they bought — is a ",[14,498,24],{}," with ",[14,501,502],{},"\", \".join"," as the aggregation:",[312,505,507],{"className":361,"code":506,"language":363,"meta":317,"style":317},"per_customer = (orders.dropna(subset=[\"product\"])\n                      .groupby(\"customer\", sort=False)[\"product\"]\n                      .agg(\", \".join)\n                      .reset_index(name=\"products\"))\nprint(per_customer.to_string(index=False))\n",[14,508,509,530,555,565,581],{"__ignoreMap":317},[321,510,511,514,516,519,522,524,526,528],{"class":280,"line":323},[321,512,513],{"class":374},"per_customer ",[321,515,397],{"class":370},[321,517,518],{"class":374}," (orders.dropna(",[321,520,521],{"class":409},"subset",[321,523,397],{"class":370},[321,525,426],{"class":374},[321,527,446],{"class":330},[321,529,432],{"class":374},[321,531,532,535,538,540,543,545,547,550,552],{"class":280,"line":384},[321,533,534],{"class":374},"                      .groupby(",[321,536,537],{"class":330},"\"customer\"",[321,539,406],{"class":374},[321,541,542],{"class":409},"sort",[321,544,397],{"class":370},[321,546,416],{"class":415},[321,548,549],{"class":374},")[",[321,551,446],{"class":330},[321,553,554],{"class":374},"]\n",[321,556,557,560,562],{"class":280,"line":391},[321,558,559],{"class":374},"                      .agg(",[321,561,461],{"class":330},[321,563,564],{"class":374},".join)\n",[321,566,567,570,573,575,578],{"class":280,"line":435},[321,568,569],{"class":374},"                      .reset_index(",[321,571,572],{"class":409},"name",[321,574,397],{"class":370},[321,576,577],{"class":330},"\"products\"",[321,579,580],{"class":374},"))\n",[321,582,583,585,588,591,593,595],{"class":280,"line":452},[321,584,455],{"class":415},[321,586,587],{"class":374},"(per_customer.to_string(",[321,589,590],{"class":409},"index",[321,592,397],{"class":370},[321,594,416],{"class":415},[321,596,580],{"class":374},[10,598,599,602,603,606,607,611,612,616],{},[14,600,601],{},"sort=False"," keeps customers in the order they first appear, like a ",[14,604,605],{},"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 ",[608,609,610],"em",{},"which"," products. Step 4 handles that. ",[35,613,615],{"href":614},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas\u002F","Count Distinct Values per Group in pandas"," gives the count that often sits next to such a list.",[307,618,620],{"id":619},"step-3-numbers-must-become-text-first","Step 3 — Numbers must become text first",[10,622,623,624,626],{},"Joining the order numbers fails, because ",[14,625,357],{}," only accepts strings:",[312,628,630],{"className":361,"code":629,"language":363,"meta":317,"style":317},"try:\n    orders.groupby(\"customer\")[\"order_no\"].agg(\", \".join)\nexcept TypeError as exc:\n    print(\"TypeError:\", exc)\n\norder_lists = orders.groupby(\"customer\", sort=False)[\"order_no\"].agg(lambda s: \", \".join(s.astype(str)))\nprint(order_lists.to_dict())\n",[14,631,632,640,659,673,686,690,734],{"__ignoreMap":317},[321,633,634,637],{"class":280,"line":323},[321,635,636],{"class":370},"try",[321,638,639],{"class":374},":\n",[321,641,642,645,647,649,652,655,657],{"class":280,"line":384},[321,643,644],{"class":374},"    orders.groupby(",[321,646,537],{"class":330},[321,648,549],{"class":374},[321,650,651],{"class":330},"\"order_no\"",[321,653,654],{"class":374},"].agg(",[321,656,461],{"class":330},[321,658,564],{"class":374},[321,660,661,664,667,670],{"class":280,"line":391},[321,662,663],{"class":370},"except",[321,665,666],{"class":415}," TypeError",[321,668,669],{"class":370}," as",[321,671,672],{"class":374}," exc:\n",[321,674,675,678,680,683],{"class":280,"line":435},[321,676,677],{"class":415},"    print",[321,679,458],{"class":374},[321,681,682],{"class":330},"\"TypeError:\"",[321,684,685],{"class":374},", exc)\n",[321,687,688],{"class":280,"line":452},[321,689,388],{"emptyLinePlaceholder":387},[321,691,693,696,698,701,703,705,707,709,711,713,715,717,720,723,725,728,731],{"class":280,"line":692},6,[321,694,695],{"class":374},"order_lists ",[321,697,397],{"class":370},[321,699,700],{"class":374}," orders.groupby(",[321,702,537],{"class":330},[321,704,406],{"class":374},[321,706,542],{"class":409},[321,708,397],{"class":370},[321,710,416],{"class":415},[321,712,549],{"class":374},[321,714,651],{"class":330},[321,716,654],{"class":374},[321,718,719],{"class":370},"lambda",[321,721,722],{"class":374}," s: ",[321,724,461],{"class":330},[321,726,727],{"class":374},".join(s.astype(",[321,729,730],{"class":415},"str",[321,732,733],{"class":374},")))\n",[321,735,737,739],{"class":280,"line":736},7,[321,738,455],{"class":415},[321,740,741],{"class":374},"(order_lists.to_dict())\n",[10,743,744,745,748,749,752,753,756,757,759,760,762],{},"Convert inside the aggregation with ",[14,746,747],{},"astype(str)",", or format the numbers first when their appearance matters — ",[14,750,751],{},"s.map(\"{:,.2f}\".format)"," for amounts, ",[14,754,755],{},"s.dt.strftime(\"%d %b\")"," for dates. ",[14,758,28],{}," 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 ",[14,761,747],{},": they are identifiers, and any thousands separator would make them harder to search for in another system.",[41,764,50,770,50,773,50,776,50,780,50,785,50,790,50,795,50,798,50,802,50,806,50,811,50,813,50,817,50,819,50,822,50,824,50,828,50,830,50,833,50,835,50,838,50,841,50,845,50,847,50,851,50,853,50,856,50,859,50,863,50,865,50,869,50,871,50,874,50,877,50,881,50,883,50,887,50,889],{"viewBox":765,"role":44,"ariaLabelledBy":766,"xmlns":48,"style":769},"4 0 752 242",[767,768],"tj-options-t","tj-options-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[52,771,772],{"id":767},"TEXTJOIN arguments and their pandas equivalents",[56,774,775],{"id":768},"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.",[60,777],{"x":155,"y":63,"width":778,"height":779,"fill":66},"752","242",[60,781],{"x":782,"y":783,"width":784,"height":70,"rx":63,"fill":66,"stroke":74,"style":75},"20.0","16.0","149.9",[60,786],{"x":787,"y":783,"width":788,"height":70,"rx":63,"fill":789,"stroke":285,"style":299},"169.9","285.0","#f0f4ff",[81,791,28],{"x":792,"y":793,"style":794},"312.4","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[60,796],{"x":797,"y":783,"width":788,"height":70,"rx":63,"fill":789,"stroke":285,"style":299},"455.0",[81,799,801],{"x":800,"y":793,"style":794},"597.5","pandas",[60,803],{"x":782,"y":804,"width":784,"height":805,"rx":63,"fill":73,"stroke":74,"style":75},"56.0","34.0",[81,807,810],{"x":71,"y":808,"style":809},"78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","Delimiter",[60,812],{"x":787,"y":804,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,814,461],{"x":792,"y":815,"style":816},"77.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle",[60,818],{"x":797,"y":804,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,820,821],{"x":800,"y":815,"style":816},"\", \".join(...)",[60,823],{"x":782,"y":303,"width":784,"height":805,"rx":63,"fill":73,"stroke":74,"style":75},[81,825,827],{"x":71,"y":826,"style":809},"112.0","Skip blanks",[60,829],{"x":787,"y":303,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,831,832],{"x":792,"y":120,"style":816},"ignore_empty = TRUE",[60,834],{"x":797,"y":303,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,836,837],{"x":800,"y":120,"style":816},"dropna, filter out \"\"",[60,839],{"x":782,"y":840,"width":784,"height":805,"rx":63,"fill":73,"stroke":74,"style":75},"124.0",[81,842,844],{"x":71,"y":843,"style":809},"146.0","Numbers",[60,846],{"x":787,"y":840,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,848,850],{"x":792,"y":849,"style":816},"145.0","converted as displayed",[60,852],{"x":797,"y":840,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,854,855],{"x":800,"y":849,"style":816},"astype(str) or a format",[60,857],{"x":782,"y":858,"width":784,"height":805,"rx":63,"fill":73,"stroke":74,"style":75},"158.0",[81,860,862],{"x":71,"y":861,"style":809},"180.0","Unique values only",[60,864],{"x":787,"y":858,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,866,868],{"x":792,"y":867,"style":816},"179.0","UNIQUE(...) inside",[60,870],{"x":797,"y":858,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,872,873],{"x":800,"y":867,"style":816},"drop_duplicates in agg",[60,875],{"x":782,"y":876,"width":784,"height":805,"rx":63,"fill":73,"stroke":74,"style":75},"192.0",[81,878,880],{"x":71,"y":879,"style":809},"214.0","Sorted list",[60,882],{"x":787,"y":876,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,884,886],{"x":792,"y":885,"style":816},"213.0","SORT(...) inside",[60,888],{"x":797,"y":876,"width":788,"height":805,"rx":63,"fill":66,"stroke":74,"style":75},[81,890,891],{"x":800,"y":885,"style":816},"sorted(...) in agg",[307,893,895],{"id":894},"step-4-unique-sorted-lists","Step 4 — Unique, sorted lists",[10,897,898,899,902],{},"\"Which products has each customer bought?\" wants each product once, usually in alphabetical order — ",[14,900,901],{},"TEXTJOIN(\", \", TRUE, SORT(UNIQUE(FILTER(...))))"," in Excel. In pandas, do both inside the aggregation function:",[312,904,906],{"className":361,"code":905,"language":363,"meta":317,"style":317},"def join_unique(values, sep=\", \"):\n    seen = values.dropna().loc[lambda s: s.str.strip() != \"\"]\n    return sep.join(sorted(seen.unique(), key=str.lower))\n\nbought = orders.groupby(\"customer\", sort=False)[\"product\"].agg(join_unique).reset_index(name=\"products\")\nprint(bought.to_string(index=False))\n",[14,907,908,927,950,974,978,1013],{"__ignoreMap":317},[321,909,910,913,917,920,922,924],{"class":280,"line":323},[321,911,912],{"class":370},"def",[321,914,916],{"class":915},"s_Opv"," join_unique",[321,918,919],{"class":374},"(values, sep",[321,921,397],{"class":370},[321,923,461],{"class":330},[321,925,926],{"class":374},"):\n",[321,928,929,932,934,937,939,942,945,948],{"class":280,"line":384},[321,930,931],{"class":374},"    seen ",[321,933,397],{"class":370},[321,935,936],{"class":374}," values.dropna().loc[",[321,938,719],{"class":370},[321,940,941],{"class":374}," s: s.str.strip() ",[321,943,944],{"class":370},"!=",[321,946,947],{"class":330}," \"\"",[321,949,554],{"class":374},[321,951,952,955,958,961,964,967,969,971],{"class":280,"line":391},[321,953,954],{"class":370},"    return",[321,956,957],{"class":374}," sep.join(",[321,959,960],{"class":415},"sorted",[321,962,963],{"class":374},"(seen.unique(), ",[321,965,966],{"class":409},"key",[321,968,397],{"class":370},[321,970,730],{"class":415},[321,972,973],{"class":374},".lower))\n",[321,975,976],{"class":280,"line":435},[321,977,388],{"emptyLinePlaceholder":387},[321,979,980,983,985,987,989,991,993,995,997,999,1001,1004,1006,1008,1010],{"class":280,"line":452},[321,981,982],{"class":374},"bought ",[321,984,397],{"class":370},[321,986,700],{"class":374},[321,988,537],{"class":330},[321,990,406],{"class":374},[321,992,542],{"class":409},[321,994,397],{"class":370},[321,996,416],{"class":415},[321,998,549],{"class":374},[321,1000,446],{"class":330},[321,1002,1003],{"class":374},"].agg(join_unique).reset_index(",[321,1005,572],{"class":409},[321,1007,397],{"class":370},[321,1009,577],{"class":330},[321,1011,1012],{"class":374},")\n",[321,1014,1015,1017,1020,1022,1024,1026],{"class":280,"line":692},[321,1016,455],{"class":415},[321,1018,1019],{"class":374},"(bought.to_string(",[321,1021,590],{"class":409},[321,1023,397],{"class":370},[321,1025,416],{"class":415},[321,1027,580],{"class":374},[10,1029,1030,1031,1033,1034,1037,1038,1041,1042,1044,1045,1047,1048,1051],{},"Northwind's list is now ",[14,1032,243],{},". Sorting with ",[14,1035,1036],{},"key=str.lower"," ignores case, the way Excel's ",[14,1039,1040],{},"SORT"," does; plain ",[14,1043,960],{}," would put capitalised names first. If the order of first purchase matters more than the alphabet, drop the ",[14,1046,960],{}," and keep ",[14,1049,1050],{},"unique()","'s order of appearance.",[307,1053,1055],{"id":1054},"show-counts-or-put-each-value-on-its-own-line","Show counts, or put each value on its own line",[10,1057,1058,1059,1061,1062,1065],{},"Two variations make joined lists more useful in reports. The first adds a count to each item — \"Widgets (2), Gadgets (1)\" — which ",[14,1060,28],{}," cannot do without helper columns. ",[14,1063,1064],{},"value_counts"," inside the aggregation produces it directly, most frequent first:",[312,1067,1069],{"className":361,"code":1068,"language":363,"meta":317,"style":317},"def join_counts(values, sep=\", \"):\n    counts = values.dropna().loc[lambda s: s.str.strip() != \"\"].value_counts()\n    return sep.join(f\"{item} ({n})\" for item, n in counts.items())\n\nprint(orders.groupby(\"customer\", sort=False)[\"product\"].agg(join_counts).to_dict())\n",[14,1070,1071,1086,1106,1153,1157],{"__ignoreMap":317},[321,1072,1073,1075,1078,1080,1082,1084],{"class":280,"line":323},[321,1074,912],{"class":370},[321,1076,1077],{"class":915}," join_counts",[321,1079,919],{"class":374},[321,1081,397],{"class":370},[321,1083,461],{"class":330},[321,1085,926],{"class":374},[321,1087,1088,1091,1093,1095,1097,1099,1101,1103],{"class":280,"line":384},[321,1089,1090],{"class":374},"    counts ",[321,1092,397],{"class":370},[321,1094,936],{"class":374},[321,1096,719],{"class":370},[321,1098,941],{"class":374},[321,1100,944],{"class":370},[321,1102,947],{"class":330},[321,1104,1105],{"class":374},"].value_counts()\n",[321,1107,1108,1110,1112,1115,1118,1122,1125,1128,1131,1133,1136,1138,1141,1144,1147,1150],{"class":280,"line":391},[321,1109,954],{"class":370},[321,1111,957],{"class":374},[321,1113,1114],{"class":370},"f",[321,1116,1117],{"class":330},"\"",[321,1119,1121],{"class":1120},"sSjpA","{",[321,1123,1124],{"class":374},"item",[321,1126,1127],{"class":1120},"}",[321,1129,1130],{"class":330}," (",[321,1132,1121],{"class":1120},[321,1134,1135],{"class":374},"n",[321,1137,1127],{"class":1120},[321,1139,1140],{"class":330},")\"",[321,1142,1143],{"class":370}," for",[321,1145,1146],{"class":374}," item, n ",[321,1148,1149],{"class":370},"in",[321,1151,1152],{"class":374}," counts.items())\n",[321,1154,1155],{"class":280,"line":435},[321,1156,388],{"emptyLinePlaceholder":387},[321,1158,1159,1161,1164,1166,1168,1170,1172,1174,1176,1178],{"class":280,"line":452},[321,1160,455],{"class":415},[321,1162,1163],{"class":374},"(orders.groupby(",[321,1165,537],{"class":330},[321,1167,406],{"class":374},[321,1169,542],{"class":409},[321,1171,397],{"class":370},[321,1173,416],{"class":415},[321,1175,549],{"class":374},[321,1177,446],{"class":330},[321,1179,1180],{"class":374},"].agg(join_counts).to_dict())\n",[10,1182,1183,1184,1187,1188,1191],{},"The second puts each value on its own line inside one cell — ",[14,1185,1186],{},"TEXTJOIN(CHAR(10), TRUE, ...)"," in Excel. Join with ",[14,1189,1190],{},"\"\\n\""," and switch on wrapping for the cell, or the line breaks will not show:",[312,1193,1195],{"className":361,"code":1194,"language":363,"meta":317,"style":317},"from openpyxl import Workbook\nfrom openpyxl.styles import Alignment\n\nwb = Workbook()\nws = wb.active\nws[\"A1\"] = \"\\n\".join(sorted(orders[\"product\"].dropna().loc[lambda s: s != \"\"].unique()))\nws[\"A1\"].alignment = Alignment(wrap_text=True, vertical=\"top\")\nws.row_dimensions[1].height = 45\nwb.save(\"lines_in_cell.xlsx\")\nprint(repr(ws[\"A1\"].value))\n",[14,1196,1197,1210,1222,1226,1236,1246,1292,1326,1342,1353],{"__ignoreMap":317},[321,1198,1199,1202,1205,1207],{"class":280,"line":323},[321,1200,1201],{"class":370},"from",[321,1203,1204],{"class":374}," openpyxl ",[321,1206,371],{"class":370},[321,1208,1209],{"class":374}," Workbook\n",[321,1211,1212,1214,1217,1219],{"class":280,"line":384},[321,1213,1201],{"class":370},[321,1215,1216],{"class":374}," openpyxl.styles ",[321,1218,371],{"class":370},[321,1220,1221],{"class":374}," Alignment\n",[321,1223,1224],{"class":280,"line":391},[321,1225,388],{"emptyLinePlaceholder":387},[321,1227,1228,1231,1233],{"class":280,"line":435},[321,1229,1230],{"class":374},"wb ",[321,1232,397],{"class":370},[321,1234,1235],{"class":374}," Workbook()\n",[321,1237,1238,1241,1243],{"class":280,"line":452},[321,1239,1240],{"class":374},"ws ",[321,1242,397],{"class":370},[321,1244,1245],{"class":374}," wb.active\n",[321,1247,1248,1251,1254,1257,1259,1262,1265,1267,1270,1272,1275,1277,1280,1282,1285,1287,1289],{"class":280,"line":692},[321,1249,1250],{"class":374},"ws[",[321,1252,1253],{"class":330},"\"A1\"",[321,1255,1256],{"class":374},"] ",[321,1258,397],{"class":370},[321,1260,1261],{"class":330}," \"",[321,1263,1264],{"class":1120},"\\n",[321,1266,1117],{"class":330},[321,1268,1269],{"class":374},".join(",[321,1271,960],{"class":415},[321,1273,1274],{"class":374},"(orders[",[321,1276,446],{"class":330},[321,1278,1279],{"class":374},"].dropna().loc[",[321,1281,719],{"class":370},[321,1283,1284],{"class":374}," s: s ",[321,1286,944],{"class":370},[321,1288,947],{"class":330},[321,1290,1291],{"class":374},"].unique()))\n",[321,1293,1294,1296,1298,1301,1303,1306,1309,1311,1314,1316,1319,1321,1324],{"class":280,"line":736},[321,1295,1250],{"class":374},[321,1297,1253],{"class":330},[321,1299,1300],{"class":374},"].alignment ",[321,1302,397],{"class":370},[321,1304,1305],{"class":374}," Alignment(",[321,1307,1308],{"class":409},"wrap_text",[321,1310,397],{"class":370},[321,1312,1313],{"class":415},"True",[321,1315,406],{"class":374},[321,1317,1318],{"class":409},"vertical",[321,1320,397],{"class":370},[321,1322,1323],{"class":330},"\"top\"",[321,1325,1012],{"class":374},[321,1327,1329,1332,1334,1337,1339],{"class":280,"line":1328},8,[321,1330,1331],{"class":374},"ws.row_dimensions[",[321,1333,103],{"class":415},[321,1335,1336],{"class":374},"].height ",[321,1338,397],{"class":370},[321,1340,1341],{"class":415}," 45\n",[321,1343,1345,1348,1351],{"class":280,"line":1344},9,[321,1346,1347],{"class":374},"wb.save(",[321,1349,1350],{"class":330},"\"lines_in_cell.xlsx\"",[321,1352,1012],{"class":374},[321,1354,1356,1358,1360,1363,1366,1368],{"class":280,"line":1355},10,[321,1357,455],{"class":415},[321,1359,458],{"class":374},[321,1361,1362],{"class":415},"repr",[321,1364,1365],{"class":374},"(ws[",[321,1367,1253],{"class":330},[321,1369,1370],{"class":374},"].value))\n",[10,1372,1373],{},"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.",[307,1375,1377],{"id":1376},"step-5-respect-excels-cell-length-limit","Step 5 — Respect Excel's cell length limit",[10,1379,1380],{},"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:",[312,1382,1384],{"className":361,"code":1383,"language":363,"meta":317,"style":317},"def join_limited(values, limit=3, sep=\", \"):\n    items = sorted(values.dropna().astype(str).unique(), key=str.lower)\n    shown = sep.join(items[:limit])\n    return shown if len(items) \u003C= limit else f\"{shown} … and {len(items) - limit} more\"\n\nmany = pd.Series([f\"INV-{n:04d}\" for n in range(1, 8)])\nprint(join_limited(many))\n",[14,1385,1386,1409,1436,1446,1504,1508,1556],{"__ignoreMap":317},[321,1387,1388,1390,1393,1396,1398,1400,1403,1405,1407],{"class":280,"line":323},[321,1389,912],{"class":370},[321,1391,1392],{"class":915}," join_limited",[321,1394,1395],{"class":374},"(values, limit",[321,1397,397],{"class":370},[321,1399,139],{"class":415},[321,1401,1402],{"class":374},", sep",[321,1404,397],{"class":370},[321,1406,461],{"class":330},[321,1408,926],{"class":374},[321,1410,1411,1414,1416,1419,1422,1424,1427,1429,1431,1433],{"class":280,"line":384},[321,1412,1413],{"class":374},"    items ",[321,1415,397],{"class":370},[321,1417,1418],{"class":415}," sorted",[321,1420,1421],{"class":374},"(values.dropna().astype(",[321,1423,730],{"class":415},[321,1425,1426],{"class":374},").unique(), ",[321,1428,966],{"class":409},[321,1430,397],{"class":370},[321,1432,730],{"class":415},[321,1434,1435],{"class":374},".lower)\n",[321,1437,1438,1441,1443],{"class":280,"line":391},[321,1439,1440],{"class":374},"    shown ",[321,1442,397],{"class":370},[321,1444,1445],{"class":374}," sep.join(items[:limit])\n",[321,1447,1448,1450,1453,1456,1459,1462,1465,1468,1471,1474,1476,1478,1481,1483,1486,1488,1491,1493,1496,1499,1501],{"class":280,"line":435},[321,1449,954],{"class":370},[321,1451,1452],{"class":374}," shown ",[321,1454,1455],{"class":370},"if",[321,1457,1458],{"class":415}," len",[321,1460,1461],{"class":374},"(items) ",[321,1463,1464],{"class":370},"\u003C=",[321,1466,1467],{"class":374}," limit ",[321,1469,1470],{"class":370},"else",[321,1472,1473],{"class":370}," f",[321,1475,1117],{"class":330},[321,1477,1121],{"class":1120},[321,1479,1480],{"class":374},"shown",[321,1482,1127],{"class":1120},[321,1484,1485],{"class":330}," … and ",[321,1487,1121],{"class":1120},[321,1489,1490],{"class":415},"len",[321,1492,1461],{"class":374},[321,1494,1495],{"class":370},"-",[321,1497,1498],{"class":374}," limit",[321,1500,1127],{"class":1120},[321,1502,1503],{"class":330}," more\"\n",[321,1505,1506],{"class":280,"line":452},[321,1507,388],{"emptyLinePlaceholder":387},[321,1509,1510,1513,1515,1518,1520,1523,1525,1527,1530,1532,1534,1536,1539,1541,1544,1546,1548,1550,1553],{"class":280,"line":692},[321,1511,1512],{"class":374},"many ",[321,1514,397],{"class":370},[321,1516,1517],{"class":374}," pd.Series([",[321,1519,1114],{"class":370},[321,1521,1522],{"class":330},"\"INV-",[321,1524,1121],{"class":1120},[321,1526,1135],{"class":374},[321,1528,1529],{"class":370},":04d",[321,1531,1127],{"class":1120},[321,1533,1117],{"class":330},[321,1535,1143],{"class":370},[321,1537,1538],{"class":374}," n ",[321,1540,1149],{"class":370},[321,1542,1543],{"class":415}," range",[321,1545,458],{"class":374},[321,1547,103],{"class":415},[321,1549,406],{"class":374},[321,1551,1552],{"class":415},"8",[321,1554,1555],{"class":374},")])\n",[321,1557,1558,1560],{"class":280,"line":736},[321,1559,455],{"class":415},[321,1561,1562],{"class":374},"(join_limited(many))\n",[10,1564,1565],{},"\"… 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.",[307,1567,1569],{"id":1568},"step-6-split-a-joined-cell-back-into-rows","Step 6 — Split a joined cell back into rows",[10,1571,1572,1573,1576,1577,1580,1581,1584],{},"The reverse operation comes up just as often: a column that already holds ",[14,1574,1575],{},"\"Widgets, Gadgets\""," needs one row per product. ",[14,1578,1579],{},"str.split"," makes lists and ",[14,1582,1583],{},"explode"," turns each list element into its own row:",[312,1586,1588],{"className":361,"code":1587,"language":363,"meta":317,"style":317},"back = bought.assign(product=bought[\"products\"].str.split(\", \")).explode(\"product\")\nback[\"product\"] = back[\"product\"].str.strip()\nprint(back[[\"customer\", \"product\"]].to_string(index=False))\n",[14,1589,1590,1621,1640],{"__ignoreMap":317},[321,1591,1592,1595,1597,1600,1602,1604,1607,1609,1612,1614,1617,1619],{"class":280,"line":323},[321,1593,1594],{"class":374},"back ",[321,1596,397],{"class":370},[321,1598,1599],{"class":374}," bought.assign(",[321,1601,114],{"class":409},[321,1603,397],{"class":370},[321,1605,1606],{"class":374},"bought[",[321,1608,577],{"class":330},[321,1610,1611],{"class":374},"].str.split(",[321,1613,461],{"class":330},[321,1615,1616],{"class":374},")).explode(",[321,1618,446],{"class":330},[321,1620,1012],{"class":374},[321,1622,1623,1626,1628,1630,1632,1635,1637],{"class":280,"line":384},[321,1624,1625],{"class":374},"back[",[321,1627,446],{"class":330},[321,1629,1256],{"class":374},[321,1631,397],{"class":370},[321,1633,1634],{"class":374}," back[",[321,1636,446],{"class":330},[321,1638,1639],{"class":374},"].str.strip()\n",[321,1641,1642,1644,1647,1649,1651,1653,1656,1658,1660,1662],{"class":280,"line":391},[321,1643,455],{"class":415},[321,1645,1646],{"class":374},"(back[[",[321,1648,537],{"class":330},[321,1650,406],{"class":374},[321,1652,446],{"class":330},[321,1654,1655],{"class":374},"]].to_string(",[321,1657,590],{"class":409},[321,1659,397],{"class":370},[321,1661,416],{"class":415},[321,1663,580],{"class":374},[10,1665,1666,1667,1670,1671,1675],{},"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 ",[14,1668,1669],{},"r\"\\s*[,;]\\s*\""," to handle both commas and semicolons and stray spaces. ",[35,1672,1674],{"href":1673},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fsplit-a-column-into-multiple-columns-with-pandas\u002F","Split a Column into Multiple Columns with pandas"," covers splitting into columns instead of rows.",[41,1677,50,1682,50,1685,50,1688,50,1691,50,1696,50,1707,50,1713,50,1717,50,1721,50,1726,50,1729,50,1732,50,1739,50,1744,50,1748,50,1752,50,1755,50,1758,50,1764,50,1767,50,1770,50,1774,50,1777,50,1780,50,1787,50,1791,50,1793],{"viewBox":1678,"role":44,"ariaLabelledBy":1679,"xmlns":48,"style":769},"4 0 752 142",[1680,1681],"tj-roundtrip-t","tj-roundtrip-d",[52,1683,1684],{"id":1680},"Joining and splitting as a round trip",[56,1686,1687],{"id":1681},"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.",[60,1689],{"x":155,"y":63,"width":778,"height":1690,"fill":66},"142",[60,1692],{"x":782,"y":783,"width":1693,"height":1694,"rx":1695,"fill":73,"stroke":74,"style":286},"153.0","110.5","12",[1697,1698,1700],"g",{"transform":1699},"translate(84.5,29.0) scale(1.000)",[1701,1702],"path",{"d":1703,"fill":1704,"stroke":1705,"style":1706},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[81,1708,1712],{"x":1709,"y":1710,"style":1711},"96.5","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Order lines",[81,1714,1716],{"x":1709,"y":1715,"style":816},"95.0","one row per product",[81,1718,1720],{"x":1709,"y":1719,"style":816},"112.5","sold",[280,1722],{"x1":1723,"y1":1724,"x2":1725,"y2":1724,"stroke":285,"style":286},"178.0","71.2","200.0",[288,1727],{"points":1728,"fill":291},"204.0,71.2 195.0,76.2 195.0,66.2",[60,1730],{"x":1731,"y":783,"width":1693,"height":1694,"rx":1695,"fill":789,"stroke":285,"style":286},"209.0",[1697,1733,1735],{"transform":1734},"translate(273.5,37.8) scale(1.000)",[1701,1736],{"d":1737,"fill":1704,"stroke":1738,"style":1706},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--brand-strong,#4338ca)",[81,1740,305],{"x":1741,"y":1742,"style":1743},"285.5","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[81,1745,1747],{"x":1741,"y":1746,"style":816},"103.8","one cell per customer",[280,1749],{"x1":1750,"y1":1724,"x2":1751,"y2":1724,"stroke":285,"style":286},"367.0","389.0",[288,1753],{"points":1754,"fill":291},"393.0,71.2 384.0,76.2 384.0,66.2",[60,1756],{"x":1757,"y":783,"width":1693,"height":1694,"rx":1695,"fill":789,"stroke":285,"style":286},"398.0",[1697,1759,1761],{"transform":1760},"translate(462.5,37.8) scale(1.000)",[1701,1762],{"d":1763,"fill":1704,"stroke":1738,"style":1706},"M4 12h6 M10 12l6-6h4 M10 12l6 6h4 M17 3l3 3-3 3 M17 15l3 3-3 3",[81,1765,1579],{"x":1766,"y":1742,"style":1743},"474.5",[81,1768,1769],{"x":1766,"y":1746,"style":816},"same delimiter",[280,1771],{"x1":1772,"y1":1724,"x2":1773,"y2":1724,"stroke":285,"style":286},"556.0","578.0",[288,1775],{"points":1776,"fill":291},"582.0,71.2 573.0,76.2 573.0,66.2",[60,1778],{"x":1779,"y":783,"width":1693,"height":1694,"rx":1695,"fill":237,"stroke":238,"style":286},"587.0",[1697,1781,1783],{"transform":1782},"translate(651.5,29.0) scale(1.000)",[1701,1784],{"d":1785,"fill":1704,"stroke":1786,"style":1706},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","var(--teal-ink,#0b6157)",[81,1788,1583],{"x":1789,"y":1710,"style":1790},"663.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[81,1792,1716],{"x":1789,"y":1715,"style":816},[81,1794,1795],{"x":1789,"y":1719,"style":816},"again",[307,1797,1799],{"id":1798},"write-the-summary","Write the summary",[10,1801,1802],{},"Write the joined lists next to other per-customer figures, and widen the column and turn on wrapping so long lists stay readable:",[312,1804,1806],{"className":361,"code":1805,"language":363,"meta":317,"style":317},"from openpyxl import load_workbook\nfrom openpyxl.styles import Alignment\n\nsummary = bought.merge(orders.groupby(\"customer\").size().rename(\"lines\").reset_index(), on=\"customer\")\nsummary.to_excel(\"customers.xlsx\", index=False)\nwb = load_workbook(\"customers.xlsx\")\nws = wb.active\nws.column_dimensions[\"B\"].width = 40\nfor (cell,) in ws.iter_rows(min_row=2, min_col=2, max_col=2):\n    cell.alignment = Alignment(wrap_text=True, vertical=\"top\")\nwb.save(\"customers.xlsx\")\nprint(pd.read_excel(\"customers.xlsx\").to_string(index=False))\n",[14,1807,1808,1819,1829,1833,1863,1881,1894,1902,1918,1958,1983,1992],{"__ignoreMap":317},[321,1809,1810,1812,1814,1816],{"class":280,"line":323},[321,1811,1201],{"class":370},[321,1813,1204],{"class":374},[321,1815,371],{"class":370},[321,1817,1818],{"class":374}," load_workbook\n",[321,1820,1821,1823,1825,1827],{"class":280,"line":384},[321,1822,1201],{"class":370},[321,1824,1216],{"class":374},[321,1826,371],{"class":370},[321,1828,1221],{"class":374},[321,1830,1831],{"class":280,"line":391},[321,1832,388],{"emptyLinePlaceholder":387},[321,1834,1835,1838,1840,1843,1845,1848,1851,1854,1857,1859,1861],{"class":280,"line":435},[321,1836,1837],{"class":374},"summary ",[321,1839,397],{"class":370},[321,1841,1842],{"class":374}," bought.merge(orders.groupby(",[321,1844,537],{"class":330},[321,1846,1847],{"class":374},").size().rename(",[321,1849,1850],{"class":330},"\"lines\"",[321,1852,1853],{"class":374},").reset_index(), ",[321,1855,1856],{"class":409},"on",[321,1858,397],{"class":370},[321,1860,537],{"class":330},[321,1862,1012],{"class":374},[321,1864,1865,1868,1871,1873,1875,1877,1879],{"class":280,"line":452},[321,1866,1867],{"class":374},"summary.to_excel(",[321,1869,1870],{"class":330},"\"customers.xlsx\"",[321,1872,406],{"class":374},[321,1874,590],{"class":409},[321,1876,397],{"class":370},[321,1878,416],{"class":415},[321,1880,1012],{"class":374},[321,1882,1883,1885,1887,1890,1892],{"class":280,"line":692},[321,1884,1230],{"class":374},[321,1886,397],{"class":370},[321,1888,1889],{"class":374}," load_workbook(",[321,1891,1870],{"class":330},[321,1893,1012],{"class":374},[321,1895,1896,1898,1900],{"class":280,"line":736},[321,1897,1240],{"class":374},[321,1899,397],{"class":370},[321,1901,1245],{"class":374},[321,1903,1904,1907,1910,1913,1915],{"class":280,"line":1328},[321,1905,1906],{"class":374},"ws.column_dimensions[",[321,1908,1909],{"class":330},"\"B\"",[321,1911,1912],{"class":374},"].width ",[321,1914,397],{"class":370},[321,1916,1917],{"class":415}," 40\n",[321,1919,1920,1923,1926,1928,1931,1934,1936,1938,1940,1943,1945,1947,1949,1952,1954,1956],{"class":280,"line":1344},[321,1921,1922],{"class":370},"for",[321,1924,1925],{"class":374}," (cell,) ",[321,1927,1149],{"class":370},[321,1929,1930],{"class":374}," ws.iter_rows(",[321,1932,1933],{"class":409},"min_row",[321,1935,397],{"class":370},[321,1937,121],{"class":415},[321,1939,406],{"class":374},[321,1941,1942],{"class":409},"min_col",[321,1944,397],{"class":370},[321,1946,121],{"class":415},[321,1948,406],{"class":374},[321,1950,1951],{"class":409},"max_col",[321,1953,397],{"class":370},[321,1955,121],{"class":415},[321,1957,926],{"class":374},[321,1959,1960,1963,1965,1967,1969,1971,1973,1975,1977,1979,1981],{"class":280,"line":1355},[321,1961,1962],{"class":374},"    cell.alignment ",[321,1964,397],{"class":370},[321,1966,1305],{"class":374},[321,1968,1308],{"class":409},[321,1970,397],{"class":370},[321,1972,1313],{"class":415},[321,1974,406],{"class":374},[321,1976,1318],{"class":409},[321,1978,397],{"class":370},[321,1980,1323],{"class":330},[321,1982,1012],{"class":374},[321,1984,1986,1988,1990],{"class":280,"line":1985},11,[321,1987,1347],{"class":374},[321,1989,1870],{"class":330},[321,1991,1012],{"class":374},[321,1993,1995,1997,2000,2002,2005,2007,2009,2011],{"class":280,"line":1994},12,[321,1996,455],{"class":415},[321,1998,1999],{"class":374},"(pd.read_excel(",[321,2001,1870],{"class":330},[321,2003,2004],{"class":374},").to_string(",[321,2006,590],{"class":409},[321,2008,397],{"class":370},[321,2010,416],{"class":415},[321,2012,580],{"class":374},[10,2014,2015,2019],{},[35,2016,2018],{"href":2017},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fwrap-text-and-align-cells-with-openpyxl\u002F","Wrap Text and Align Cells with openpyxl"," covers the alignment options.",[307,2021,2023],{"id":2022},"common-pitfalls","Common pitfalls",[2025,2026,2027,2042,2048,2057,2063,2069],"ul",{},[2028,2029,2030,2036,2037,481,2039,2041],"li",{},[2031,2032,2033,2035],"strong",{},[14,2034,477],{}," in the values."," ",[14,2038,357],{},[14,2040,484],{},"; drop missing values first.",[2028,2043,2044,2047],{},[2031,2045,2046],{},"Numbers in the values."," Convert to text, ideally with a deliberate format.",[2028,2049,2050,2053,2054,2056],{},[2031,2051,2052],{},"Duplicates you did not want."," Join ",[14,2055,1050],{}," values when the list names things rather than lines.",[2028,2058,2059,2062],{},[2031,2060,2061],{},"Very long cells."," Stay well under 32,767 characters; summarise and link to details.",[2028,2064,2065,2068],{},[2031,2066,2067],{},"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.",[2028,2070,2071,2074],{},[2031,2072,2073],{},"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.",[307,2076,2078],{"id":2077},"performance-notes","Performance notes",[10,2080,2081,2082,2085],{},"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 — ",[14,2083,2084],{},"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.",[307,2087,2089],{"id":2088},"conclusion","Conclusion",[10,2091,2092,2094,2095,2097,2098,2101,2102,2104,2105,2107],{},[14,2093,28],{}," in pandas is ",[14,2096,821],{}," over the non-missing values of a column, and per group it is ",[14,2099,2100],{},"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 ",[14,2103,1579],{}," and ",[14,2106,1583],{},".",[307,2109,2111],{"id":2110},"frequently-asked-questions","Frequently asked questions",[10,2113,2114,2117,2118,2121],{},[2031,2115,2116],{},"What is the pandas equivalent of TEXTJOIN?","\nFor a whole column, \", \".join(values) over the non-missing values. Per group, df.groupby(\"key\")",[321,2119,2120],{},"\"col\"",".agg(\", \".join) after dropping missing values. Both put a delimiter between the values the way TEXTJOIN does.",[10,2123,2124,2127],{},[2031,2125,2126],{},"How do I skip blanks like TEXTJOIN's ignore_empty?","\nRemove 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.",[10,2129,2130,2133],{},[2031,2131,2132],{},"Why does join fail on my number column?","\nstr.join only accepts strings. Convert first with astype(str) — or format the numbers the way you want them to appear — and then join.",[10,2135,2136,2139],{},[2031,2137,2138],{},"Is there a limit on how long the joined text can be?","\nExcel 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.",[307,2141,2143],{"id":2142},"related","Related",[2025,2145,2146,2152,2159,2166],{},[2028,2147,2148,2149,2151],{},"Up one level: ",[35,2150,38],{"href":37}," — every function, translated.",[2028,2153,2154,2158],{},[35,2155,2157],{"href":2156},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fexcel-text-functions-left-right-mid-and-concat-in-pandas\u002F","Excel Text Functions LEFT, RIGHT, MID and CONCAT in pandas"," — joining a few columns in one row.",[2028,2160,2161,2165],{},[35,2162,2164],{"href":2163},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Funique-sort-and-filter-equivalents-in-pandas\u002F","UNIQUE, SORT and FILTER Equivalents in pandas"," — the functions usually nested inside TEXTJOIN.",[2028,2167,2168,2172],{},[35,2169,2171],{"href":2170},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fparse-records-that-span-multiple-rows-in-excel\u002F","Parse Records That Span Multiple Rows in Excel with pandas"," — combining continuation rows.",[2174,2175,2176],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":317,"searchDepth":384,"depth":384,"links":2178},[2179,2180,2181,2182,2183,2184,2185,2186,2187,2188,2189,2190,2191,2192],{"id":309,"depth":384,"text":310},{"id":346,"depth":384,"text":347},{"id":492,"depth":384,"text":493},{"id":619,"depth":384,"text":620},{"id":894,"depth":384,"text":895},{"id":1054,"depth":384,"text":1055},{"id":1376,"depth":384,"text":1377},{"id":1568,"depth":384,"text":1569},{"id":1798,"depth":384,"text":1799},{"id":2022,"depth":384,"text":2023},{"id":2077,"depth":384,"text":2078},{"id":2088,"depth":384,"text":2089},{"id":2110,"depth":384,"text":2111},{"id":2142,"depth":384,"text":2143},"2026-09-25","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.","md",[2197,2199,2201,2203],{"q":2116,"a":2198},"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.",{"q":2126,"a":2200},"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.",{"q":2132,"a":2202},"str.join only accepts strings. Convert first with astype(str) — or format the numbers the way you want them to appear — and then join.",{"q":2138,"a":2204},"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.","textjoin equivalent in pandas join values per group",null,{"breadcrumb":2208},[2209,2212,2215],{"name":2210,"item":2211},"Home","\u002F",{"name":2213,"item":2214},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":38,"item":37},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas",{"title":2218,"description":2219},"Excel TEXTJOIN in pandas: Join Values per Group into One Cell","Reproduce TEXTJOIN in pandas: \", \".join over a column, groupby agg to join per group, ignore_empty with dropna, numbers as text, unique sorted lists, the 32,767-character cell limit, and str.split with explode to reverse it.","textjoin-equivalent-in-pandas","advanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextjoin-equivalent-in-pandas\u002Findex",[2223,2224,801],"formula-equivalents","pivots-and-summaries","how-to","J5K2wOWGZscMy2EJizkZ9-mUuc06GRBgHtpSvnbLn4Y",[2228,2232],{"title":2229,"path":2230,"stem":2231,"children":-1},"TEXTBEFORE, TEXTAFTER and TEXTSPLIT Equivalents in pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextbefore-textafter-and-textsplit-equivalents-in-pandas","advanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Ftextbefore-textafter-and-textsplit-equivalents-in-pandas\u002Findex",{"title":2164,"path":2233,"stem":2234,"children":-1},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Funique-sort-and-filter-equivalents-in-pandas","advanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Funique-sort-and-filter-equivalents-in-pandas\u002Findex",[2236,2238],{"path":2237,"title":38},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas",{"path":2239,"title":2213},"\u002Fadvanced-data-transformation-and-cleaning",[],[2242,2245,2248,2251,2254,2257],{"path":2243,"title":2244,"kind":2225},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fvlookup-equivalent-in-pandas-for-excel-files","The VLOOKUP Equivalent in pandas for Excel Files",{"path":2246,"title":2247,"kind":2225},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fextract-numbers-and-codes-from-text-with-pandas","Extract Numbers and Codes from Text with pandas",{"path":2249,"title":2250,"kind":2225},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fsplit-full-names-into-first-and-last-names-with-pandas","Split Full Names into First and Last Names with pandas",{"path":2252,"title":2253,"kind":2225},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbin-values-into-ranges-with-pandas-cut","Bin Values into Ranges Like Excel FREQUENCY with pd.cut",{"path":2255,"title":2256,"kind":2225},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcalculate-percent-of-total-per-group-in-pandas","Calculate Percent of Total per Group in pandas",{"path":2258,"title":615,"kind":2225},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas",1790972088778]