[{"data":1,"prerenderedAt":1908},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas":1874,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas":1883,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas":1888,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas":1889},{"id":4,"title":5,"body":6,"dateModified":1839,"datePublished":1839,"description":1840,"extension":1841,"faq":1842,"keyword":1851,"kind":1852,"meta":1853,"navigation":219,"path":1862,"seo":1863,"slug":1866,"stem":1867,"topics":1868,"type":1872,"__hash__":1873},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas\u002Findex.md","Count Distinct Values per Group in pandas",{"type":7,"value":8,"toc":1822},"minimark",[9,28,140,145,172,182,186,191,304,320,324,327,403,411,594,598,603,668,675,679,682,803,813,879,887,891,894,946,953,1027,1031,1034,1196,1204,1208,1211,1328,1335,1339,1342,1507,1541,1545,1548,1677,1681,1716,1720,1742,1746,1757,1761,1767,1773,1779,1785,1789,1818],[10,11,12,13,17,18,21,22,27],"p",{},"\"How many customers bought in each region?\" is not the same as \"how many orders were there?\", and it is the first question almost every sales report asks. In a classic Excel pivot table, the only way to get a distinct count is to add the data to the Data Model; the everyday workaround is a helper column of ",[14,15,16],"code",{},"COUNTIFS"," flags. In pandas it is a single ",[14,19,20],{},"nunique"," — with two properties worth understanding before a report is built on it: distinct counts depend on how clean the values are, and they do not add up across groups. This page, part of ",[23,24,26],"a",{"href":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002F","Creating Pivot Tables from Excel Data with Pandas",", covers both.",[29,30,38,39,38,43,38,47,38,55,38,65,38,76,38,83,38,90,38,95,38,99,38,103,38,107,38,112,38,119,38,124,38,128,38,131,38,134,38,137],"svg",{"viewBox":31,"role":32,"ariaLabelledBy":33,"xmlns":36,"style":37},"4 0 752 233","img",[34,35],"dc-count-t","dc-count-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[40,41,42],"title",{"id":34},"Counting orders versus counting customers",[44,45,46],"desc",{"id":35},"Two panels for the North region in January. Counting rows gives three orders. Counting distinct customers gives two, because customer C1 placed two of the orders. A report of customers per region needs the distinct count.",[48,49],"rect",{"x":50,"y":51,"width":52,"height":53,"fill":54},"4","0","752","233","#ffffff",[48,56],{"x":57,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"20.0","16.0","351.0","201.0","14","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[66,67,69],"g",{"transform":68},"translate(182.5,29.0) scale(1.083)",[70,71],"path",{"d":72,"fill":73,"stroke":74,"style":75},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","none","var(--text,#172033)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[77,78,82],"text",{"x":79,"y":80,"style":81},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","count() — orders",[84,85],"line",{"x1":86,"y1":87,"x2":88,"y2":87,"stroke":63,"style":89},"36.0","91.0","355.0","stroke-width:1px",[77,91,94],{"x":79,"y":92,"style":93},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","C1 · Widgets",[77,96,98],{"x":79,"y":97,"style":93},"137.0","C2 · Gadgets",[77,100,102],{"x":79,"y":101,"style":93},"163.0","C1 · Gadgets",[77,104,106],{"x":79,"y":105,"style":93},"189.0","= 3 orders",[48,108],{"x":109,"y":58,"width":59,"height":60,"rx":61,"fill":110,"stroke":111,"style":64},"389.0","#d9f4f1","var(--teal,#0f9488)",[66,113,115],{"transform":114},"translate(551.5,29.0) scale(1.083)",[70,116],{"d":117,"fill":73,"stroke":118,"style":75},"M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6 M16 4.5a3.3 3.3 0 0 1 0 6.3 M18 14.3c2.2.7 3.5 2.8 3.5 5.7","var(--teal-ink,#0b6157)",[77,120,123],{"x":121,"y":80,"style":122},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","nunique() — customers",[84,125],{"x1":126,"y1":87,"x2":127,"y2":87,"stroke":111,"style":89},"405.0","724.0",[77,129,130],{"x":121,"y":92,"style":93},"C1",[77,132,133],{"x":121,"y":97,"style":93},"C2",[77,135,136],{"x":121,"y":101,"style":93},"(C1 again, not counted)",[77,138,139],{"x":121,"y":105,"style":93},"= 2 customers",[141,142,144],"h2",{"id":143},"prerequisites","Prerequisites",[146,147,152],"pre",{"className":148,"code":149,"language":150,"meta":151,"style":151},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[14,153,154],{"__ignoreMap":151},[155,156,158,162,166,169],"span",{"class":84,"line":157},1,[155,159,161],{"class":160},"sMTad","pip",[155,163,165],{"class":164},"srMev"," install",[155,167,168],{"class":164}," pandas",[155,170,171],{"class":164}," openpyxl\n",[10,173,174,177,178,181],{},[14,175,176],{},"orders.xlsx"," has nine orders over two months and two regions. One customer ID is stored as ",[14,179,180],{},"\" c2\""," with a space and lower case, and one order has no customer at all.",[141,183,185],{"id":184},"step-1-count-distinct-values-per-group-with-nunique","Step 1 — Count distinct values per group with nunique",[10,187,188,190],{},[14,189,20],{}," on a grouped column returns the number of different values in each group, next to the row count for comparison:",[146,192,196],{"className":193,"code":194,"language":195,"meta":151,"style":151},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\norders = pd.read_excel(\"orders.xlsx\")\nsummary = orders.groupby(\"region\").agg(orders=(\"customer\", \"size\"), customers=(\"customer\", \"nunique\"))\nprint(summary)\n","python",[14,197,198,214,221,239,294],{"__ignoreMap":151},[155,199,200,204,208,211],{"class":84,"line":157},[155,201,203],{"class":202},"s-kum","import",[155,205,207],{"class":206},"skGVy"," pandas ",[155,209,210],{"class":202},"as",[155,212,213],{"class":206}," pd\n",[155,215,217],{"class":84,"line":216},2,[155,218,220],{"emptyLinePlaceholder":219},true,"\n",[155,222,224,227,230,233,236],{"class":84,"line":223},3,[155,225,226],{"class":206},"orders ",[155,228,229],{"class":202},"=",[155,231,232],{"class":206}," pd.read_excel(",[155,234,235],{"class":164},"\"orders.xlsx\"",[155,237,238],{"class":206},")\n",[155,240,242,245,247,250,253,256,260,262,265,268,271,274,277,280,282,284,286,288,291],{"class":84,"line":241},4,[155,243,244],{"class":206},"summary ",[155,246,229],{"class":202},[155,248,249],{"class":206}," orders.groupby(",[155,251,252],{"class":164},"\"region\"",[155,254,255],{"class":206},").agg(",[155,257,259],{"class":258},"sa561","orders",[155,261,229],{"class":202},[155,263,264],{"class":206},"(",[155,266,267],{"class":164},"\"customer\"",[155,269,270],{"class":206},", ",[155,272,273],{"class":164},"\"size\"",[155,275,276],{"class":206},"), ",[155,278,279],{"class":258},"customers",[155,281,229],{"class":202},[155,283,264],{"class":206},[155,285,267],{"class":164},[155,287,270],{"class":206},[155,289,290],{"class":164},"\"nunique\"",[155,292,293],{"class":206},"))\n",[155,295,297,301],{"class":84,"line":296},5,[155,298,300],{"class":299},"sP0c6","print",[155,302,303],{"class":206},"(summary)\n",[10,305,306,309,310,312,313,316,317,319],{},[14,307,308],{},"size"," counts rows, including the one with no customer; ",[14,311,20],{}," counts different customer IDs. North shows 5 orders and — for now — 3 customers, one more than it really has, because ",[14,314,315],{},"\"C2\""," and ",[14,318,180],{}," are different strings. The next step fixes that.",[141,321,323],{"id":322},"step-2-normalise-values-before-counting","Step 2 — Normalise values before counting",[10,325,326],{},"Distinct counts are exact comparisons, so every spelling variant is a separate value. Clean the key column first — strip spaces, fix case — and count on the cleaned version:",[146,328,330],{"className":193,"code":329,"language":195,"meta":151,"style":151},"orders[\"customer_key\"] = orders[\"customer\"].str.strip().str.upper()\nsummary = orders.groupby(\"region\").agg(orders=(\"customer\", \"size\"),\n                                       customers=(\"customer_key\", \"nunique\"))\nprint(summary)\n",[14,331,332,353,380,397],{"__ignoreMap":151},[155,333,334,337,340,343,345,348,350],{"class":84,"line":157},[155,335,336],{"class":206},"orders[",[155,338,339],{"class":164},"\"customer_key\"",[155,341,342],{"class":206},"] ",[155,344,229],{"class":202},[155,346,347],{"class":206}," orders[",[155,349,267],{"class":164},[155,351,352],{"class":206},"].str.strip().str.upper()\n",[155,354,355,357,359,361,363,365,367,369,371,373,375,377],{"class":84,"line":216},[155,356,244],{"class":206},[155,358,229],{"class":202},[155,360,249],{"class":206},[155,362,252],{"class":164},[155,364,255],{"class":206},[155,366,259],{"class":258},[155,368,229],{"class":202},[155,370,264],{"class":206},[155,372,267],{"class":164},[155,374,270],{"class":206},[155,376,273],{"class":164},[155,378,379],{"class":206},"),\n",[155,381,382,385,387,389,391,393,395],{"class":84,"line":223},[155,383,384],{"class":258},"                                       customers",[155,386,229],{"class":202},[155,388,264],{"class":206},[155,390,339],{"class":164},[155,392,270],{"class":206},[155,394,290],{"class":164},[155,396,293],{"class":206},[155,398,399,401],{"class":84,"line":241},[155,400,300],{"class":299},[155,402,303],{"class":206},[10,404,405,406,410],{},"North now has two customers, C1 and C2. For keys with more variation — names rather than IDs — the same idea applies with a stronger normaliser; ",[23,407,409],{"href":408},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fstandardise-category-values-with-a-mapping-table\u002F","Standardise Category Values with a Mapping Table in pandas"," shows one. Keep the original column untouched and count on a derived key, so the cleaning is visible.",[29,412,38,418,38,421,38,424,38,429,38,435,38,439,38,445,38,449,38,454,38,456,38,460,38,463,38,466,38,468,38,470,38,473,38,477,38,479,38,481,38,484,38,487,38,489,38,491,38,494,38,498,38,503,38,506,38,509,38,513,38,516,38,518,38,521,38,523,38,526,38,528,38,530,38,532,38,534,38,536,38,538,38,540,38,542,38,544,38,546,38,548,38,550,38,552,38,554,38,556,38,559,38,565,38,569,38,575,38,580,38,588],{"viewBox":413,"role":32,"ariaLabelledBy":414,"xmlns":36,"style":417},"133 0 494 220",[415,416],"dc-normalise-t","dc-normalise-d","width:100%;max-width:494px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[40,419,420],{"id":415},"Normalising customer IDs before counting",[44,422,423],{"id":416},"Left: North's customer IDs C1, C2, C1, C1 and \" c2\" count as three distinct values because \" c2\" differs in spacing and case. Right: after strip and upper they become C1, C2, C1, C1 and C2, which count as two.",[48,425],{"x":426,"y":51,"width":427,"height":428,"fill":54},"133","494","220",[48,430],{"x":431,"y":432,"width":433,"height":434,"rx":51,"fill":62,"stroke":63,"style":89},"159.4","40.0","32.0","24.0",[48,436],{"x":437,"y":432,"width":438,"height":434,"rx":51,"fill":62,"stroke":63,"style":89},"191.4","82.8",[77,440,444],{"x":441,"y":442,"style":443},"232.8","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[48,446],{"x":431,"y":447,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},"64.0","28.0",[77,450,453],{"x":451,"y":452,"style":443},"175.4","83.0","1",[48,455],{"x":437,"y":447,"width":438,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,457,459],{"x":441,"y":452,"style":458},"font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","customer",[48,461],{"x":431,"y":462,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},"92.0",[77,464,465],{"x":451,"y":92,"style":443},"2",[48,467],{"x":437,"y":462,"width":438,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,469,130],{"x":441,"y":92,"style":93},[48,471],{"x":431,"y":472,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},"120.0",[77,474,476],{"x":451,"y":475,"style":443},"139.0","3",[48,478],{"x":437,"y":472,"width":438,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,480,133],{"x":441,"y":475,"style":93},[48,482],{"x":431,"y":483,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},"148.0",[77,485,50],{"x":451,"y":486,"style":443},"167.0",[48,488],{"x":437,"y":483,"width":438,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,490,130],{"x":441,"y":486,"style":93},[48,492],{"x":431,"y":493,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},"176.0",[77,495,497],{"x":451,"y":496,"style":443},"195.0","5",[48,499],{"x":437,"y":493,"width":438,"height":448,"rx":51,"fill":500,"stroke":501,"style":502},"#fee8f2","var(--accent,#f43f8f)","stroke-width:1.5px",[77,504,180],{"x":441,"y":496,"style":505},"font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle",[48,507],{"x":508,"y":432,"width":433,"height":434,"rx":51,"fill":62,"stroke":63,"style":89},"457.4",[48,510],{"x":511,"y":432,"width":512,"height":434,"rx":51,"fill":62,"stroke":63,"style":89},"489.4","111.2",[77,514,444],{"x":515,"y":442,"style":443},"545.0",[48,517],{"x":508,"y":447,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},[77,519,453],{"x":520,"y":452,"style":443},"473.4",[48,522],{"x":511,"y":447,"width":512,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,524,525],{"x":515,"y":452,"style":458},"customer_key",[48,527],{"x":508,"y":462,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},[77,529,465],{"x":520,"y":92,"style":443},[48,531],{"x":511,"y":462,"width":512,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,533,130],{"x":515,"y":92,"style":93},[48,535],{"x":508,"y":472,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},[77,537,476],{"x":520,"y":475,"style":443},[48,539],{"x":511,"y":472,"width":512,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,541,133],{"x":515,"y":475,"style":93},[48,543],{"x":508,"y":483,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},[77,545,50],{"x":520,"y":486,"style":443},[48,547],{"x":511,"y":483,"width":512,"height":448,"rx":51,"fill":54,"stroke":63,"style":89},[77,549,130],{"x":515,"y":486,"style":93},[48,551],{"x":508,"y":493,"width":433,"height":448,"rx":51,"fill":62,"stroke":63,"style":89},[77,553,497],{"x":520,"y":496,"style":443},[48,555],{"x":511,"y":493,"width":512,"height":448,"rx":51,"fill":110,"stroke":111,"style":502},[77,557,133],{"x":515,"y":496,"style":558},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[77,560,564],{"x":561,"y":562,"style":563},"216.8","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","customer (3 distinct)",[77,566,568],{"x":567,"y":562,"style":563},"529.0","customer_key (2 distinct)",[84,570],{"x1":571,"y1":572,"x2":573,"y2":572,"stroke":574,"style":64},"284.2","122.0","443.4","var(--brand,#5b5cf0)",[576,577],"polygon",{"points":578,"fill":579},"447.4,122.0 438.4,127.0 438.4,117.0","#5b5cf0",[48,581],{"x":582,"y":583,"width":584,"height":585,"rx":586,"fill":54,"stroke":63,"style":587},"308.6","89.0","114.5","22.0","11","stroke-width:1.2px",[77,589,593],{"x":590,"y":591,"style":592},"365.8","104.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","strip + upper",[141,595,597],{"id":596},"step-3-decide-whether-blanks-count","Step 3 — Decide whether blanks count",[10,599,600,602],{},[14,601,20],{}," ignores missing values by default. The South order with no customer contributes nothing to South's distinct count — correct if a blank is simply missing data, misleading if \"unknown customer\" is something the report should surface:",[146,604,606],{"className":193,"code":605,"language":195,"meta":151,"style":151},"print(orders.groupby(\"region\")[\"customer_key\"].nunique().to_dict())\nprint(orders.groupby(\"region\")[\"customer_key\"].nunique(dropna=False).to_dict())\nprint(\"orders with no customer:\", orders[\"customer_key\"].isna().sum())\n",[14,607,608,625,651],{"__ignoreMap":151},[155,609,610,612,615,617,620,622],{"class":84,"line":157},[155,611,300],{"class":299},[155,613,614],{"class":206},"(orders.groupby(",[155,616,252],{"class":164},[155,618,619],{"class":206},")[",[155,621,339],{"class":164},[155,623,624],{"class":206},"].nunique().to_dict())\n",[155,626,627,629,631,633,635,637,640,643,645,648],{"class":84,"line":216},[155,628,300],{"class":299},[155,630,614],{"class":206},[155,632,252],{"class":164},[155,634,619],{"class":206},[155,636,339],{"class":164},[155,638,639],{"class":206},"].nunique(",[155,641,642],{"class":258},"dropna",[155,644,229],{"class":202},[155,646,647],{"class":299},"False",[155,649,650],{"class":206},").to_dict())\n",[155,652,653,655,657,660,663,665],{"class":84,"line":223},[155,654,300],{"class":299},[155,656,264],{"class":206},[155,658,659],{"class":164},"\"orders with no customer:\"",[155,661,662],{"class":206},", orders[",[155,664,339],{"class":164},[155,666,667],{"class":206},"].isna().sum())\n",[10,669,670,671,674],{},"With ",[14,672,673],{},"dropna=False",", South counts the blank as one more distinct value. Usually the better report does neither: count real customers with the default, and show the number of orders with no customer as a separate data quality line, so the gap is visible without distorting the customer count.",[141,676,678],{"id":677},"step-4-several-distinct-counts-at-once","Step 4 — Several distinct counts at once",[10,680,681],{},"Named aggregation computes several distinct counts, and other statistics, in one pass — customers, products and revenue per region and month:",[146,683,685],{"className":193,"code":684,"language":195,"meta":151,"style":151},"monthly = (orders.groupby([\"month\", \"region\"])\n                 .agg(customers=(\"customer_key\", \"nunique\"),\n                      products=(\"product\", \"nunique\"),\n                      orders=(\"amount\", \"size\"),\n                      revenue=(\"amount\", \"sum\"))\n                 .reset_index())\nprint(monthly.to_string(index=False))\n",[14,686,687,707,726,744,762,780,786],{"__ignoreMap":151},[155,688,689,692,694,697,700,702,704],{"class":84,"line":157},[155,690,691],{"class":206},"monthly ",[155,693,229],{"class":202},[155,695,696],{"class":206}," (orders.groupby([",[155,698,699],{"class":164},"\"month\"",[155,701,270],{"class":206},[155,703,252],{"class":164},[155,705,706],{"class":206},"])\n",[155,708,709,712,714,716,718,720,722,724],{"class":84,"line":216},[155,710,711],{"class":206},"                 .agg(",[155,713,279],{"class":258},[155,715,229],{"class":202},[155,717,264],{"class":206},[155,719,339],{"class":164},[155,721,270],{"class":206},[155,723,290],{"class":164},[155,725,379],{"class":206},[155,727,728,731,733,735,738,740,742],{"class":84,"line":223},[155,729,730],{"class":258},"                      products",[155,732,229],{"class":202},[155,734,264],{"class":206},[155,736,737],{"class":164},"\"product\"",[155,739,270],{"class":206},[155,741,290],{"class":164},[155,743,379],{"class":206},[155,745,746,749,751,753,756,758,760],{"class":84,"line":241},[155,747,748],{"class":258},"                      orders",[155,750,229],{"class":202},[155,752,264],{"class":206},[155,754,755],{"class":164},"\"amount\"",[155,757,270],{"class":206},[155,759,273],{"class":164},[155,761,379],{"class":206},[155,763,764,767,769,771,773,775,778],{"class":84,"line":296},[155,765,766],{"class":258},"                      revenue",[155,768,229],{"class":202},[155,770,264],{"class":206},[155,772,755],{"class":164},[155,774,270],{"class":206},[155,776,777],{"class":164},"\"sum\"",[155,779,293],{"class":206},[155,781,783],{"class":84,"line":782},6,[155,784,785],{"class":206},"                 .reset_index())\n",[155,787,789,791,794,797,799,801],{"class":84,"line":788},7,[155,790,300],{"class":299},[155,792,793],{"class":206},"(monthly.to_string(",[155,795,796],{"class":258},"index",[155,798,229],{"class":202},[155,800,647],{"class":299},[155,802,293],{"class":206},[10,804,805,806,809,810,812],{},"For a pivot-style layout — months across, regions down — ",[14,807,808],{},"pivot_table"," accepts ",[14,811,290],{}," as the aggregation:",[146,814,816],{"className":193,"code":815,"language":195,"meta":151,"style":151},"grid = pd.pivot_table(orders, index=\"region\", columns=\"month\", values=\"customer_key\", aggfunc=\"nunique\", fill_value=0)\nprint(grid)\n",[14,817,818,872],{"__ignoreMap":151},[155,819,820,823,825,828,830,832,834,836,839,841,843,845,848,850,852,854,857,859,861,863,866,868,870],{"class":84,"line":157},[155,821,822],{"class":206},"grid ",[155,824,229],{"class":202},[155,826,827],{"class":206}," pd.pivot_table(orders, ",[155,829,796],{"class":258},[155,831,229],{"class":202},[155,833,252],{"class":164},[155,835,270],{"class":206},[155,837,838],{"class":258},"columns",[155,840,229],{"class":202},[155,842,699],{"class":164},[155,844,270],{"class":206},[155,846,847],{"class":258},"values",[155,849,229],{"class":202},[155,851,339],{"class":164},[155,853,270],{"class":206},[155,855,856],{"class":258},"aggfunc",[155,858,229],{"class":202},[155,860,290],{"class":164},[155,862,270],{"class":206},[155,864,865],{"class":258},"fill_value",[155,867,229],{"class":202},[155,869,51],{"class":299},[155,871,238],{"class":206},[155,873,874,876],{"class":84,"line":216},[155,875,300],{"class":299},[155,877,878],{"class":206},"(grid)\n",[10,880,881,882,886],{},"That is the pandas version of an Excel pivot table with a Distinct Count value field, without the Data Model. ",[23,883,885],{"href":884},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fuse-different-aggregations-per-column-in-a-pivot\u002F","Use Different Aggregations per Column in a pandas Pivot"," covers mixing functions in one pivot.",[141,888,890],{"id":889},"step-5-do-not-add-distinct-counts-across-groups","Step 5 — Do not add distinct counts across groups",[10,892,893],{},"Distinct counts are not additive. A customer who buys in both months counts once in each month but only once for the whole period, so summing monthly counts overstates the total:",[146,895,897],{"className":193,"code":896,"language":195,"meta":151,"style":151},"by_month = orders.groupby(\"month\")[\"customer_key\"].nunique()\nprint(by_month.to_dict(), \"sum:\", by_month.sum())\nprint(\"distinct customers overall:\", orders[\"customer_key\"].nunique())\n",[14,898,899,917,930],{"__ignoreMap":151},[155,900,901,904,906,908,910,912,914],{"class":84,"line":157},[155,902,903],{"class":206},"by_month ",[155,905,229],{"class":202},[155,907,249],{"class":206},[155,909,699],{"class":164},[155,911,619],{"class":206},[155,913,339],{"class":164},[155,915,916],{"class":206},"].nunique()\n",[155,918,919,921,924,927],{"class":84,"line":216},[155,920,300],{"class":299},[155,922,923],{"class":206},"(by_month.to_dict(), ",[155,925,926],{"class":164},"\"sum:\"",[155,928,929],{"class":206},", by_month.sum())\n",[155,931,932,934,936,939,941,943],{"class":84,"line":223},[155,933,300],{"class":299},[155,935,264],{"class":206},[155,937,938],{"class":164},"\"distinct customers overall:\"",[155,940,662],{"class":206},[155,942,339],{"class":164},[155,944,945],{"class":206},"].nunique())\n",[10,947,948,949,952],{},"January has 3 customers and February 4, which sum to 7 — but there are only 4 different customers across both months, because C1, C2 and C3 bought in each. Totals in a distinct-count report must be computed from the underlying data, never by summing the group figures. The same applies to subtotal rows: ",[14,950,951],{},"pivot_table(..., margins=True)"," recomputes the margin from the data for each row and column, which is correct, while a hand-built total row that sums the cells is not.",[29,954,38,960,38,963,38,966,38,970,38,974,38,980,38,985,38,989,38,992,38,996,38,999,38,1002,38,1005,38,1008,38,1011,38,1015,38,1018,38,1022,38,1024],{"viewBox":955,"role":32,"ariaLabelledBy":956,"xmlns":36,"style":959},"4 0 743 180",[957,958],"dc-additive-t","dc-additive-d","width:100%;max-width:743px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[40,961,962],{"id":957},"Monthly distinct customers do not sum to the period total",[44,964,965],{"id":958},"Bars showing 3 distinct customers in January and 4 in February. Adding them gives 7, but the distinct count for the whole period is 4, because three customers bought in both months.",[48,967],{"x":50,"y":51,"width":968,"height":969,"fill":54},"743","180",[48,971],{"x":57,"y":58,"width":972,"height":448,"rx":973,"fill":62,"stroke":63,"style":89},"207.9","7",[77,975,979],{"x":976,"y":977,"style":978},"123.9","34.5","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","January",[48,981],{"x":982,"y":58,"width":983,"height":448,"rx":973,"fill":984,"stroke":574,"style":502},"237.9","204.7","#f0f4ff",[77,986,476],{"x":987,"y":977,"style":988},"450.6","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:start",[48,990],{"x":57,"y":991,"width":972,"height":448,"rx":973,"fill":62,"stroke":63,"style":89},"56.0",[77,993,995],{"x":976,"y":994,"style":978},"74.5","February",[48,997],{"x":982,"y":991,"width":998,"height":448,"rx":973,"fill":984,"stroke":574,"style":502},"272.9",[77,1000,50],{"x":1001,"y":994,"style":988},"518.8",[48,1003],{"x":57,"y":1004,"width":972,"height":448,"rx":973,"fill":62,"stroke":63,"style":89},"96.0",[77,1006,1007],{"x":976,"y":584,"style":978},"January + February (wrong)",[48,1009],{"x":982,"y":1004,"width":1010,"height":448,"rx":973,"fill":500,"stroke":501,"style":502},"477.6",[77,1012,973],{"x":1013,"y":584,"style":1014},"723.5","font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:start",[48,1016],{"x":57,"y":1017,"width":972,"height":448,"rx":973,"fill":62,"stroke":63,"style":89},"136.0",[77,1019,1021],{"x":976,"y":1020,"style":978},"154.5","Both months (distinct)",[48,1023],{"x":982,"y":1017,"width":998,"height":448,"rx":973,"fill":110,"stroke":111,"style":502},[77,1025,50],{"x":1001,"y":1020,"style":1026},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start",[141,1028,1030],{"id":1029},"step-6-new-and-returning-customers","Step 6 — New and returning customers",[10,1032,1033],{},"Once distinct counts are right, the next question is usually which customers are new. Find each customer's first month, then count distinct customers by whether the order month is their first:",[146,1035,1037],{"className":193,"code":1036,"language":195,"meta":151,"style":151},"first_month = orders.dropna(subset=[\"customer_key\"]).groupby(\"customer_key\")[\"month\"].min()\norders[\"first_month\"] = orders[\"customer_key\"].map(first_month)\norders[\"status\"] = (orders[\"month\"] == orders[\"first_month\"]).map({True: \"new\", False: \"returning\"})\ncohort = (orders.dropna(subset=[\"customer_key\"])\n                .groupby([\"month\", \"status\"])[\"customer_key\"].nunique()\n                .unstack(fill_value=0))\nprint(cohort)\n",[14,1038,1039,1071,1089,1138,1158,1176,1189],{"__ignoreMap":151},[155,1040,1041,1044,1046,1049,1052,1054,1057,1059,1062,1064,1066,1068],{"class":84,"line":157},[155,1042,1043],{"class":206},"first_month ",[155,1045,229],{"class":202},[155,1047,1048],{"class":206}," orders.dropna(",[155,1050,1051],{"class":258},"subset",[155,1053,229],{"class":202},[155,1055,1056],{"class":206},"[",[155,1058,339],{"class":164},[155,1060,1061],{"class":206},"]).groupby(",[155,1063,339],{"class":164},[155,1065,619],{"class":206},[155,1067,699],{"class":164},[155,1069,1070],{"class":206},"].min()\n",[155,1072,1073,1075,1078,1080,1082,1084,1086],{"class":84,"line":216},[155,1074,336],{"class":206},[155,1076,1077],{"class":164},"\"first_month\"",[155,1079,342],{"class":206},[155,1081,229],{"class":202},[155,1083,347],{"class":206},[155,1085,339],{"class":164},[155,1087,1088],{"class":206},"].map(first_month)\n",[155,1090,1091,1093,1096,1098,1100,1103,1105,1107,1110,1112,1114,1117,1120,1123,1126,1128,1130,1132,1135],{"class":84,"line":223},[155,1092,336],{"class":206},[155,1094,1095],{"class":164},"\"status\"",[155,1097,342],{"class":206},[155,1099,229],{"class":202},[155,1101,1102],{"class":206}," (orders[",[155,1104,699],{"class":164},[155,1106,342],{"class":206},[155,1108,1109],{"class":202},"==",[155,1111,347],{"class":206},[155,1113,1077],{"class":164},[155,1115,1116],{"class":206},"]).map({",[155,1118,1119],{"class":299},"True",[155,1121,1122],{"class":206},": ",[155,1124,1125],{"class":164},"\"new\"",[155,1127,270],{"class":206},[155,1129,647],{"class":299},[155,1131,1122],{"class":206},[155,1133,1134],{"class":164},"\"returning\"",[155,1136,1137],{"class":206},"})\n",[155,1139,1140,1143,1145,1148,1150,1152,1154,1156],{"class":84,"line":241},[155,1141,1142],{"class":206},"cohort ",[155,1144,229],{"class":202},[155,1146,1147],{"class":206}," (orders.dropna(",[155,1149,1051],{"class":258},[155,1151,229],{"class":202},[155,1153,1056],{"class":206},[155,1155,339],{"class":164},[155,1157,706],{"class":206},[155,1159,1160,1163,1165,1167,1169,1172,1174],{"class":84,"line":296},[155,1161,1162],{"class":206},"                .groupby([",[155,1164,699],{"class":164},[155,1166,270],{"class":206},[155,1168,1095],{"class":164},[155,1170,1171],{"class":206},"])[",[155,1173,339],{"class":164},[155,1175,916],{"class":206},[155,1177,1178,1181,1183,1185,1187],{"class":84,"line":782},[155,1179,1180],{"class":206},"                .unstack(",[155,1182,865],{"class":258},[155,1184,229],{"class":202},[155,1186,51],{"class":299},[155,1188,293],{"class":206},[155,1190,1191,1193],{"class":84,"line":788},[155,1192,300],{"class":299},[155,1194,1195],{"class":206},"(cohort)\n",[10,1197,1198,1199,1203],{},"In February, three customers were returning and one — C4 — was new. The new and returning counts within a month ",[1200,1201,1202],"em",{},"do"," add up to that month's distinct total, because every customer has exactly one status per month; it is only across months that distinct counts stop being additive.",[141,1205,1207],{"id":1206},"active-customers-over-a-rolling-window","Active customers over a rolling window",[10,1209,1210],{},"\"Active customers\" is usually defined over a window — anyone who ordered in the last two, three or twelve months. Because distinct counts cannot be added, a rolling distinct count cannot be built by summing monthly figures either; count over each window's rows directly:",[146,1212,1214],{"className":193,"code":1213,"language":195,"meta":151,"style":151},"months = sorted(orders[\"month\"].unique())\nactive = {}\nfor i, m in enumerate(months):\n    window = months[max(0, i - 1): i + 1]                    # this month and the one before\n    active[m] = orders.loc[orders[\"month\"].isin(window), \"customer_key\"].nunique()\nprint(active)\n",[14,1215,1216,1234,1244,1261,1302,1321],{"__ignoreMap":151},[155,1217,1218,1221,1223,1226,1229,1231],{"class":84,"line":157},[155,1219,1220],{"class":206},"months ",[155,1222,229],{"class":202},[155,1224,1225],{"class":299}," sorted",[155,1227,1228],{"class":206},"(orders[",[155,1230,699],{"class":164},[155,1232,1233],{"class":206},"].unique())\n",[155,1235,1236,1239,1241],{"class":84,"line":216},[155,1237,1238],{"class":206},"active ",[155,1240,229],{"class":202},[155,1242,1243],{"class":206}," {}\n",[155,1245,1246,1249,1252,1255,1258],{"class":84,"line":223},[155,1247,1248],{"class":202},"for",[155,1250,1251],{"class":206}," i, m ",[155,1253,1254],{"class":202},"in",[155,1256,1257],{"class":299}," enumerate",[155,1259,1260],{"class":206},"(months):\n",[155,1262,1263,1266,1268,1271,1274,1276,1278,1281,1284,1287,1290,1293,1295,1298],{"class":84,"line":241},[155,1264,1265],{"class":206},"    window ",[155,1267,229],{"class":202},[155,1269,1270],{"class":206}," months[",[155,1272,1273],{"class":299},"max",[155,1275,264],{"class":206},[155,1277,51],{"class":299},[155,1279,1280],{"class":206},", i ",[155,1282,1283],{"class":202},"-",[155,1285,1286],{"class":299}," 1",[155,1288,1289],{"class":206},"): i ",[155,1291,1292],{"class":202},"+",[155,1294,1286],{"class":299},[155,1296,1297],{"class":206},"]                    ",[155,1299,1301],{"class":1300},"s-wDw","# this month and the one before\n",[155,1303,1304,1307,1309,1312,1314,1317,1319],{"class":84,"line":296},[155,1305,1306],{"class":206},"    active[m] ",[155,1308,229],{"class":202},[155,1310,1311],{"class":206}," orders.loc[orders[",[155,1313,699],{"class":164},[155,1315,1316],{"class":206},"].isin(window), ",[155,1318,339],{"class":164},[155,1320,916],{"class":206},[155,1322,1323,1325],{"class":84,"line":782},[155,1324,300],{"class":299},[155,1326,1327],{"class":206},"(active)\n",[10,1329,1330,1331,1334],{},"February's two-month window has 4 active customers — the same four who appear anywhere in January or February — not the 7 you would get by adding the monthly counts. For long histories, the same idea scales by keeping one row per customer and month (",[14,1332,1333],{},"drop_duplicates",") before looping, so each window counts a small table rather than every order.",[141,1336,1338],{"id":1337},"a-distinct-count-the-workbook-can-recalculate","A distinct count the workbook can recalculate",[10,1340,1341],{},"When readers need a distinct count that updates as they edit the sheet, write a formula rather than a number. The classic version works in every Excel release: each value contributes one over the number of times it appears, so a value appearing three times adds three thirds — one:",[146,1343,1345],{"className":193,"code":1344,"language":195,"meta":151,"style":151},"from openpyxl import load_workbook\n\norders[[\"customer_key\"]].to_excel(\"keys.xlsx\", index=False)\nwb = load_workbook(\"keys.xlsx\")\nws = wb.active\nlast = ws.max_row\nws[\"C1\"] = \"distinct customers\"\nws[\"C2\"] = f'=SUMPRODUCT((A2:A{last}\u003C>\"\")\u002FCOUNTIF(A2:A{last},A2:A{last}&\"\"))'\nwb.save(\"keys.xlsx\")\nprint(ws[\"C2\"].value)\n",[14,1346,1347,1360,1364,1387,1401,1411,1421,1436,1484,1494],{"__ignoreMap":151},[155,1348,1349,1352,1355,1357],{"class":84,"line":157},[155,1350,1351],{"class":202},"from",[155,1353,1354],{"class":206}," openpyxl ",[155,1356,203],{"class":202},[155,1358,1359],{"class":206}," load_workbook\n",[155,1361,1362],{"class":84,"line":216},[155,1363,220],{"emptyLinePlaceholder":219},[155,1365,1366,1369,1371,1374,1377,1379,1381,1383,1385],{"class":84,"line":223},[155,1367,1368],{"class":206},"orders[[",[155,1370,339],{"class":164},[155,1372,1373],{"class":206},"]].to_excel(",[155,1375,1376],{"class":164},"\"keys.xlsx\"",[155,1378,270],{"class":206},[155,1380,796],{"class":258},[155,1382,229],{"class":202},[155,1384,647],{"class":299},[155,1386,238],{"class":206},[155,1388,1389,1392,1394,1397,1399],{"class":84,"line":241},[155,1390,1391],{"class":206},"wb ",[155,1393,229],{"class":202},[155,1395,1396],{"class":206}," load_workbook(",[155,1398,1376],{"class":164},[155,1400,238],{"class":206},[155,1402,1403,1406,1408],{"class":84,"line":296},[155,1404,1405],{"class":206},"ws ",[155,1407,229],{"class":202},[155,1409,1410],{"class":206}," wb.active\n",[155,1412,1413,1416,1418],{"class":84,"line":782},[155,1414,1415],{"class":206},"last ",[155,1417,229],{"class":202},[155,1419,1420],{"class":206}," ws.max_row\n",[155,1422,1423,1426,1429,1431,1433],{"class":84,"line":788},[155,1424,1425],{"class":206},"ws[",[155,1427,1428],{"class":164},"\"C1\"",[155,1430,342],{"class":206},[155,1432,229],{"class":202},[155,1434,1435],{"class":164}," \"distinct customers\"\n",[155,1437,1439,1441,1443,1445,1447,1450,1453,1457,1460,1463,1466,1468,1470,1472,1475,1477,1479,1481],{"class":84,"line":1438},8,[155,1440,1425],{"class":206},[155,1442,315],{"class":164},[155,1444,342],{"class":206},[155,1446,229],{"class":202},[155,1448,1449],{"class":202}," f",[155,1451,1452],{"class":164},"'=SUMPRODUCT((A2:A",[155,1454,1456],{"class":1455},"sSjpA","{",[155,1458,1459],{"class":206},"last",[155,1461,1462],{"class":1455},"}",[155,1464,1465],{"class":164},"\u003C>\"\")\u002FCOUNTIF(A2:A",[155,1467,1456],{"class":1455},[155,1469,1459],{"class":206},[155,1471,1462],{"class":1455},[155,1473,1474],{"class":164},",A2:A",[155,1476,1456],{"class":1455},[155,1478,1459],{"class":206},[155,1480,1462],{"class":1455},[155,1482,1483],{"class":164},"&\"\"))'\n",[155,1485,1487,1490,1492],{"class":84,"line":1486},9,[155,1488,1489],{"class":206},"wb.save(",[155,1491,1376],{"class":164},[155,1493,238],{"class":206},[155,1495,1497,1499,1502,1504],{"class":84,"line":1496},10,[155,1498,300],{"class":299},[155,1500,1501],{"class":206},"(ws[",[155,1503,315],{"class":164},[155,1505,1506],{"class":206},"].value)\n",[10,1508,1509,1510,1513,1514,1517,1518,1521,1522,1524,1525,1528,1529,1533,1534,1537,1538,1540],{},"The ",[14,1511,1512],{},"(A2:A10\u003C>\"\")"," term skips blanks, and appending ",[14,1515,1516],{},"&\"\""," to the criteria stops ",[14,1519,1520],{},"COUNTIF"," from returning zero for empty cells, which would otherwise make the formula divide by zero. Recalculated in LibreOffice, the formula gives the same 4 as ",[14,1523,20],{},". In Microsoft 365, ",[14,1526,1527],{},"=COUNTA(UNIQUE(FILTER(A2:A10,A2:A10\u003C>\"\")))"," reads more naturally — ",[23,1530,1532],{"href":1531},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Fwrite-dynamic-array-formulas-with-python\u002F","Write Dynamic Array Formulas like FILTER and UNIQUE from Python"," covers writing those — but the ",[14,1535,1536],{},"SUMPRODUCT"," form also works for anyone on an older version. Either way, the formula depends on the cleaned key column: it counts spelling variants as separate values just as ",[14,1539,20],{}," does.",[141,1542,1544],{"id":1543},"write-the-report","Write the report",[10,1546,1547],{},"Write the summary with its correctly computed totals:",[146,1549,1551],{"className":193,"code":1550,"language":195,"meta":151,"style":151},"with pd.ExcelWriter(\"customer_counts.xlsx\") as writer:\n    monthly.to_excel(writer, sheet_name=\"By month and region\", index=False)\n    grid.to_excel(writer, sheet_name=\"Customers grid\")\n    pd.DataFrame({\"distinct customers\": [orders[\"customer_key\"].nunique()],\n                  \"orders without customer\": [int(orders[\"customer_key\"].isna().sum())]}).to_excel(\n        writer, sheet_name=\"Totals\", index=False)\nprint(pd.ExcelFile(\"customer_counts.xlsx\").sheet_names)\n",[14,1552,1553,1572,1595,1609,1625,1643,1665],{"__ignoreMap":151},[155,1554,1555,1558,1561,1564,1567,1569],{"class":84,"line":157},[155,1556,1557],{"class":202},"with",[155,1559,1560],{"class":206}," pd.ExcelWriter(",[155,1562,1563],{"class":164},"\"customer_counts.xlsx\"",[155,1565,1566],{"class":206},") ",[155,1568,210],{"class":202},[155,1570,1571],{"class":206}," writer:\n",[155,1573,1574,1577,1580,1582,1585,1587,1589,1591,1593],{"class":84,"line":216},[155,1575,1576],{"class":206},"    monthly.to_excel(writer, ",[155,1578,1579],{"class":258},"sheet_name",[155,1581,229],{"class":202},[155,1583,1584],{"class":164},"\"By month and region\"",[155,1586,270],{"class":206},[155,1588,796],{"class":258},[155,1590,229],{"class":202},[155,1592,647],{"class":299},[155,1594,238],{"class":206},[155,1596,1597,1600,1602,1604,1607],{"class":84,"line":223},[155,1598,1599],{"class":206},"    grid.to_excel(writer, ",[155,1601,1579],{"class":258},[155,1603,229],{"class":202},[155,1605,1606],{"class":164},"\"Customers grid\"",[155,1608,238],{"class":206},[155,1610,1611,1614,1617,1620,1622],{"class":84,"line":241},[155,1612,1613],{"class":206},"    pd.DataFrame({",[155,1615,1616],{"class":164},"\"distinct customers\"",[155,1618,1619],{"class":206},": [orders[",[155,1621,339],{"class":164},[155,1623,1624],{"class":206},"].nunique()],\n",[155,1626,1627,1630,1633,1636,1638,1640],{"class":84,"line":296},[155,1628,1629],{"class":164},"                  \"orders without customer\"",[155,1631,1632],{"class":206},": [",[155,1634,1635],{"class":299},"int",[155,1637,1228],{"class":206},[155,1639,339],{"class":164},[155,1641,1642],{"class":206},"].isna().sum())]}).to_excel(\n",[155,1644,1645,1648,1650,1652,1655,1657,1659,1661,1663],{"class":84,"line":782},[155,1646,1647],{"class":206},"        writer, ",[155,1649,1579],{"class":258},[155,1651,229],{"class":202},[155,1653,1654],{"class":164},"\"Totals\"",[155,1656,270],{"class":206},[155,1658,796],{"class":258},[155,1660,229],{"class":202},[155,1662,647],{"class":299},[155,1664,238],{"class":206},[155,1666,1667,1669,1672,1674],{"class":84,"line":788},[155,1668,300],{"class":299},[155,1670,1671],{"class":206},"(pd.ExcelFile(",[155,1673,1563],{"class":164},[155,1675,1676],{"class":206},").sheet_names)\n",[141,1678,1680],{"id":1679},"common-pitfalls","Common pitfalls",[1682,1683,1684,1692,1698,1707],"ul",{},[1685,1686,1687,1691],"li",{},[1688,1689,1690],"strong",{},"Counting on raw IDs."," Case and spacing variants inflate the count; normalise first.",[1685,1693,1694,1697],{},[1688,1695,1696],{},"Summing distinct counts."," Compute totals from the data.",[1685,1699,1700,1703,1704,1706],{},[1688,1701,1702],{},"Silent blanks."," ",[14,1705,20],{}," ignores them; report orders without a key separately.",[1685,1708,1709,1715],{},[1688,1710,1711,1714],{},[14,1712,1713],{},"count()"," for customers."," It counts non-missing rows, which is orders, not customers.",[141,1717,1719],{"id":1718},"performance-notes","Performance notes",[10,1721,1722,1725,1726,1729,1730,1733,1734,1737,1738,1741],{},[14,1723,1724],{},"groupby().nunique()"," is optimised and handles millions of rows quickly, though it is slower than ",[14,1727,1728],{},"sum"," or ",[14,1731,1732],{},"count"," because it has to track values per group. For very large data with few groups, ",[14,1735,1736],{},"drop_duplicates([\"region\", \"customer_key\"])"," followed by ",[14,1739,1740],{},"groupby(\"region\").size()"," is an equivalent and often faster formulation.",[141,1743,1745],{"id":1744},"conclusion","Conclusion",[10,1747,1748,1749,1752,1753,1756],{},"Count distinct values per group with ",[14,1750,1751],{},"groupby(...)[col].nunique()",", on a normalised key so spelling variants do not inflate the count. Decide explicitly how blanks are treated, use named aggregation or ",[14,1754,1755],{},"pivot_table(aggfunc=\"nunique\")"," for several counts at once, and never sum distinct counts across groups — compute totals from the underlying rows. The same care gives correct new-versus-returning counts.",[141,1758,1760],{"id":1759},"frequently-asked-questions","Frequently asked questions",[10,1762,1763,1766],{},[1688,1764,1765],{},"How do I count unique values per group in pandas?","\nGroup by the region and call nunique on the customer column. It returns the number of different customers in each region, the same as a pivot table's Distinct Count.",[10,1768,1769,1772],{},[1688,1770,1771],{},"Does nunique count blank cells?","\nNo. By default missing values are ignored. Pass dropna=False to count \"missing\" as one more distinct value — useful when a blank customer is itself something the report should show.",[10,1774,1775,1778],{},[1688,1776,1777],{},"Why do my distinct counts per region not add up to the total?","\nBecause the same value can appear in several groups. A customer who bought in North and South counts once in each region but only once in the company total. Distinct counts are not additive; compute the total separately.",[10,1780,1781,1784],{},[1688,1782,1783],{},"Why is \"C2\" counted twice?","\nProbably because it is stored with different case or spacing, such as \"C2\" and \" c2\". nunique compares values exactly, so normalise with str.strip() and str.upper() before counting.",[141,1786,1788],{"id":1787},"related","Related",[1682,1790,1791,1797,1804,1811],{},[1685,1792,1793,1794,1796],{},"Up one level: ",[23,1795,26],{"href":25}," — pivots and summaries.",[1685,1798,1799,1803],{},[23,1800,1802],{"href":1801},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fcountif-and-countifs-equivalent-in-pandas\u002F","COUNTIF and COUNTIFS Equivalent in pandas"," — conditional counts of rows.",[1685,1805,1806,1810],{},[23,1807,1809],{"href":1808},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fpandas-drop-duplicates-from-excel-column\u002F","Pandas: Drop Duplicates From an Excel Column"," — removing the repeats instead of counting around them.",[1685,1812,1813,1817],{},[23,1814,1816],{"href":1815},"\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"," — listing the distinct values themselves.",[1819,1820,1821],"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-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":151,"searchDepth":216,"depth":216,"links":1823},[1824,1825,1826,1827,1828,1829,1830,1831,1832,1833,1834,1835,1836,1837,1838],{"id":143,"depth":216,"text":144},{"id":184,"depth":216,"text":185},{"id":322,"depth":216,"text":323},{"id":596,"depth":216,"text":597},{"id":677,"depth":216,"text":678},{"id":889,"depth":216,"text":890},{"id":1029,"depth":216,"text":1030},{"id":1206,"depth":216,"text":1207},{"id":1337,"depth":216,"text":1338},{"id":1543,"depth":216,"text":1544},{"id":1679,"depth":216,"text":1680},{"id":1718,"depth":216,"text":1719},{"id":1744,"depth":216,"text":1745},{"id":1759,"depth":216,"text":1760},{"id":1787,"depth":216,"text":1788},"2026-09-25","Count unique customers per region, distinct products per order or active users per month — the \"Distinct Count\" that Excel pivot tables only offer through the Data Model. Use nunique, decide how blanks, case and spacing count, get several distinct counts at once, and avoid adding distinct counts across groups.","md",[1843,1845,1847,1849],{"q":1765,"a":1844},"Group by the region and call nunique on the customer column. It returns the number of different customers in each region, the same as a pivot table's Distinct Count.",{"q":1771,"a":1846},"No. By default missing values are ignored. Pass dropna=False to count \"missing\" as one more distinct value — useful when a blank customer is itself something the report should show.",{"q":1777,"a":1848},"Because the same value can appear in several groups. A customer who bought in North and South counts once in each region but only once in the company total. Distinct counts are not additive; compute the total separately.",{"q":1783,"a":1850},"Probably because it is stored with different case or spacing, such as \"C2\" and \" c2\". nunique compares values exactly, so normalise with str.strip() and str.upper() before counting.","count unique values per group distinct count",null,{"breadcrumb":1854},[1855,1858,1861],{"name":1856,"item":1857},"Home","\u002F",{"name":1859,"item":1860},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":26,"item":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas",{"title":1864,"description":1865},"Distinct Count per Group in pandas (nunique, Unique Customers)","Count distinct values per group in pandas with nunique: unique customers per region, blanks and dropna, normalising case and spaces first, distinct counts in pivot tables, and why per-group distinct counts do not add up to the total.","count-distinct-values-per-group-in-pandas","advanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcount-distinct-values-per-group-in-pandas\u002Findex",[1869,1870,1871],"pivots-and-summaries","formula-equivalents","pandas","how-to","YWV2YBjucPVK5eNemnX3FFqQll2k84iZf48ywovOzcE",[1875,1879],{"title":1876,"path":1877,"stem":1878,"children":-1},"Compare Two Periods Side by Side in a Pivot","\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcompare-two-periods-side-by-side-in-a-pivot","advanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcompare-two-periods-side-by-side-in-a-pivot\u002Findex",{"title":1880,"path":1881,"stem":1882,"children":-1},"Create a Pivot Table from Excel with Pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcreate-pivot-table-from-excel-with-pandas","advanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcreate-pivot-table-from-excel-with-pandas\u002Findex",[1884,1886],{"path":1885,"title":26},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data",{"path":1887,"title":1859},"\u002Fadvanced-data-transformation-and-cleaning",[],[1890,1893,1896,1899,1902,1905],{"path":1891,"title":1892,"kind":1872},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-customer-churn-report-from-subscription-data","Build a Customer Churn Report from Subscription Data",{"path":1894,"title":1895,"kind":1872},"\u002Fgetting-started-with-python-excel-automation\u002Fautomating-excel-with-xlwings-basics\u002Frefresh-pivot-tables-and-queries-in-excel-with-python","Refresh Excel Pivot Tables and Queries with Python",{"path":1897,"title":1898,"kind":1872},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fadd-a-native-excel-pivot-table-with-python","Add a Native Excel Pivot Table with Python",{"path":1900,"title":1901,"kind":1872},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fadd-subtotals-and-grand-totals-to-a-pandas-pivot-table","Add Subtotals and Grand Totals to a pandas Pivot Table",{"path":1903,"title":1904,"kind":1872},"\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":1906,"title":1907,"kind":1872},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fbuild-a-cohort-retention-table-from-excel-data","Build a Cohort Retention Table from Excel Data",1790972086406]