Run a Pareto Analysis on Excel Data with pandas
The 80/20 rule is less a law than a question: which few customers, products or causes account for most of the total? A Pareto analysis answers it with a sorted table and a running share — and the answer tells a business where to spend its attention. The mechanics are short in pandas, but two details are regularly got wrong: rows must be aggregated before ranking, and the item that pushes the running total past 80% has to be counted inside the 80%, not just outside it. This page, part of Creating Pivot Tables from Excel Data with Pandas, builds the table, an ABC classification and a native Excel Pareto chart.
Prerequisites
pip install pandas openpyxl
orders.xlsx has twenty orders for ten customers — each customer appears twice — adding up to 100,000 in revenue.
Step 1 — Aggregate before you rank
A Pareto analysis ranks items, so every row for the same customer has to be combined first. Ranking the raw orders would split a big customer across several lines and understate its share:
import pandas as pd
orders = pd.read_excel("orders.xlsx")
by_customer = (orders.groupby("customer", as_index=False)["amount"].sum()
.sort_values("amount", ascending=False, ignore_index=True))
print(by_customer.to_string(index=False))
print("total:", by_customer["amount"].sum())
Each customer now has one row, largest first. ignore_index=True renumbers the rows 0, 1, 2… in rank order, which is handy for counting how many items a threshold needs.
Step 2 — Compute the cumulative share
Divide each amount by the total to get its share, and take the running sum of the shares:
total = by_customer["amount"].sum()
by_customer["share"] = by_customer["amount"] / total
by_customer["cum_share"] = by_customer["share"].cumsum()
by_customer["rank"] = range(1, len(by_customer) + 1)
by_customer["item_share"] = by_customer["rank"] / len(by_customer)
print(by_customer.round(3).to_string(index=False))
cum_share climbs from 0.42 after Northwind to 0.73 after Contoso and 0.85 after Fabrikam, then flattens. item_share — the rank as a fraction of the item count — gives the other half of the 80/20 statement: after three customers, 30% of the customer base, the business has 85% of its revenue. Real data rarely lands on exactly 80/20, and saying "30% of customers bring 85% of revenue" is more useful than forcing the familiar numbers.
Step 3 — Count the items needed to reach 80%
The question "how many customers make up 80%?" has an off-by-one trap. Filtering on cum_share <= 0.8 returns the customers before the threshold is reached — Northwind and Contoso, 73% — and misses Fabrikam, whose revenue carries the total past 80%. Test the cumulative share before each item instead:
by_customer["prev_cum"] = by_customer["cum_share"] - by_customer["share"]
top = by_customer[by_customer["prev_cum"] < 0.8]
print(len(top), "customers reach", round(top["cum_share"].iloc[-1], 3), "of revenue:", top["customer"].tolist())
wrong = by_customer[by_customer["cum_share"] <= 0.8]
print("with cum_share <= 0.8:", len(wrong), "customers,", round(wrong["cum_share"].iloc[-1], 3))
prev_cum < 0.8 says "the total so far had not yet reached 80% when this item was added", which is exactly the set of items needed to get there: three customers covering 85%. The other filter reports two customers covering 73%, which answers a different question — how many items stay under 80% — that nobody asked.
Step 4 — Classify items into A, B and C
ABC classification extends the same idea to three tiers: A items bring the first 80% of the total, B the next 15%, C the last 5%. Use the same "before this item" rule for each boundary so every item lands in the tier where it starts:
import numpy as np
by_customer["class"] = np.select(
[by_customer["prev_cum"] < 0.80, by_customer["prev_cum"] < 0.95],
["A", "B"], default="C")
summary = by_customer.groupby("class").agg(customers=("customer", "count"), revenue=("amount", "sum"))
summary["revenue_share"] = (summary["revenue"] / total).round(3)
print(by_customer[["customer", "amount", "cum_share", "class"]].round(3).to_string(index=False))
print(summary)
Three A customers carry 85% of revenue, two B customers the next 10%, and five C customers the last 5%. The 80/95 thresholds are a convention; inventory planners often use 70/90, and some teams set the cut-offs by count instead of value. Keep the thresholds as named constants so the rule is visible and easy to change. Excel IF Formulas as pandas Conditional Columns covers np.select in more detail.
Step 5 — Write the table and a native Pareto chart
A Pareto chart combines columns for the sorted amounts with a line for the cumulative share on a secondary axis. openpyxl builds both as native Excel charts that readers can restyle:
from openpyxl import load_workbook
from openpyxl.chart import BarChart, LineChart, Reference
out = by_customer[["customer", "amount", "cum_share", "class"]]
out.to_excel("pareto.xlsx", index=False, sheet_name="Pareto")
wb = load_workbook("pareto.xlsx")
ws = wb["Pareto"]
n = len(out) + 1
for (cell,) in ws.iter_rows(min_row=2, min_col=3, max_col=3):
cell.number_format = "0%"
bars = BarChart()
bars.x_axis.delete = False # Excel hides axes unless this is set
bars.y_axis.delete = False
bars.title = "Revenue by customer"
bars.add_data(Reference(ws, min_col=2, min_row=1, max_row=n), titles_from_data=True)
bars.set_categories(Reference(ws, min_col=1, min_row=2, max_row=n))
bars.y_axis.title = "Revenue"
line = LineChart()
line.x_axis.delete = False
line.y_axis.delete = False
line.add_data(Reference(ws, min_col=3, min_row=1, max_row=n), titles_from_data=True)
line.y_axis.axId = 200
line.y_axis.title = "Cumulative share"
line.y_axis.number_format = "0%"
line.y_axis.scaling.max = 1
line.y_axis.crosses = "max"
bars += line
bars.width, bars.height = 22, 11
ws.add_chart(bars, "F2")
wb.save("pareto.xlsx")
print(len(load_workbook("pareto.xlsx")["Pareto"]._charts), "chart")
Giving the line its own axis id and setting it to cross at the maximum puts the cumulative scale on the right-hand side, fixed from 0 to 100%. The chart reads its data from the cells, so if the table is refreshed with next month's figures in the same place, the chart follows. Add a Combo Chart with a Secondary Axis in openpyxl explains the axis settings in depth.
Say it in one sentence
The table and chart are for analysts; the people who act on a Pareto analysis usually want one line in an email or at the top of a dashboard. Generate it from the same numbers, so it can never disagree with the table:
n_top, n_all = len(top), len(by_customer)
sentence = (f"{n_top} of {n_all} customers ({n_top / n_all:.0%}) bring "
f"{top['cum_share'].iloc[-1]:.0%} of revenue: {', '.join(top['customer'])}.")
print(sentence)
"3 of 10 customers (30%) bring 85% of revenue: Northwind, Contoso, Fabrikam." Written to a cell above the table or into the body of the report email, it states the finding in the terms readers already use, and it updates itself on the next run.
Step 6 — Run it per group
Pareto questions are often asked within a segment — which products make up 80% of each region's sales. Compute the cumulative share inside each group by sorting within groups and using groupby().cumsum():
orders["region"] = ["North" if i % 3 else "South" for i in range(len(orders))]
per = (orders.groupby(["region", "customer"], as_index=False)["amount"].sum()
.sort_values(["region", "amount"], ascending=[True, False]))
per["share"] = per["amount"] / per.groupby("region")["amount"].transform("sum")
per["prev_cum"] = per.groupby("region")["share"].cumsum() - per["share"]
print(per[per["prev_cum"] < 0.8].groupby("region")["customer"].count().to_dict())
Each region gets its own count of customers needed to reach 80% of its revenue. Calculate Percent of Total per Group in pandas explains the transform pattern used for the group shares.
Common pitfalls
- Ranking rows instead of items. Aggregate by customer or product first.
cum_share <= 0.8. It excludes the item that crosses the line; use the cumulative share before the item.- Forcing the 80/20 story. Report the actual split — "30% of customers, 85% of revenue".
- Negative amounts. Credit notes can make shares exceed 100% part-way; net them per item before ranking.
Performance notes
Grouping, sorting and cumsum are all vectorised; a Pareto table over millions of rows takes well under a second. The chart is the slowest part of the job and still negligible. Charts with thousands of categories are unreadable, so for long tails chart the A and B items and show C as a single bar.
Conclusion
Aggregate to one row per item, sort descending, compute each item's share and the running share with cumsum, and count the items needed for 80% using the cumulative share before each item so the crossing item is included. Extend the same rule to A/B/C classes, write the table with a native Pareto chart — columns plus a cumulative line on a secondary axis — and run it within groups with groupby().cumsum() when the question is asked per segment.
Frequently asked questions
What is a Pareto analysis? It ranks items by their contribution to a total and shows the cumulative share, to find the few items that account for most of it — often around 80% of revenue from about 20% of customers, though the actual split varies.
Is the item that crosses 80% in the top group? It should be. The items needed to reach 80% include the one that takes the running total past the line. Classify on the cumulative share before the item is added — prev_cum < 0.8 — so the crossing item counts as A.
What is ABC classification? A three-tier version of the same idea: A items make up the first 80% of the total, B items the next 15%, and C items the last 5%. The thresholds are conventions; inventory teams often use 70/20/10 instead.
How do I make a Pareto chart in Excel from Python? Write the sorted amounts and the cumulative share, then build a bar chart of the amounts and a line chart of the cumulative share on a secondary axis with openpyxl, and combine them.
Related
- Up one level: Creating Pivot Tables from Excel Data with Pandas — pivots, subtotals and exports.
- Running Totals and Year-Over-Year Growth in pandas — cumulative sums over time.
- Get the Top N Rows per Group in pandas — a fixed count instead of a share.
- Style Chart Colours and Data Labels with openpyxl — finishing the chart.
More on pivots & summaries
- How-toBuild a Customer Churn Report from Subscription Data
- How-toRefresh Excel Pivot Tables and Queries with Python
- How-toAdd a Native Excel Pivot Table with Python
- How-toAdd Subtotals and Grand Totals to a pandas Pivot Table
- How-toBin Values into Ranges Like Excel FREQUENCY with pd.cut
- How-toBuild a Cohort Retention Table from Excel Data