How-to
Advanced Data Transformation And CleaningDeep dive

Run an ABC-XYZ Inventory Classification

Classify stock items by how much they matter and how predictable they are with pandas: ABC classes from cumulative share of consumption value, XYZ classes from the coefficient of variation of monthly demand, the nine-cell ABC-XYZ matrix with item counts and value shares, a stocking policy per cell, items with too little history, and an Excel matrix sheet a planner can use.

A warehouse with two thousand stock items cannot manage every one with the same care. ABC analysis sorts items by value — the few A items that make up most of the money deserve the closest attention — and is covered on its own in Run a Pareto Analysis on Excel Data with pandas. But value is only half the story. An A item that sells a steady 200 units every month is easy to plan; an A item that sells 50 one month and 600 the next is where stock-outs and excess stock come from. XYZ analysis adds that second dimension — how variable demand is — and the combined ABC-XYZ matrix tells a planner which items can be replenished automatically and which need judgement. This page builds both classifications from monthly demand data with pandas, puts them in the nine-cell matrix, attaches a stocking policy to each cell, and writes the result to Excel. It belongs to Creating Pivot Tables from Excel Data.

Prerequisites

Bash
pip install pandas openpyxl

demand.xlsx has twelve months of demand, October 2025 to September 2026, for 240 stock items: SKU, month, units issued and unit cost. Some items sell steadily, some seasonally, some in occasional large lumps, and a handful were introduced only in the last three months.

Step 1 — ABC by consumption value

ABC classes rank items by annual consumption value — units times cost — and cut the ranking where the cumulative share of total value reaches set thresholds, commonly 80% and 95%:

Python
import pandas as pd

demand = pd.read_excel("demand.xlsx")
demand["Value"] = demand["Units"] * demand["Unit cost"]
items = demand.groupby("SKU").agg(units=("Units", "sum"), value=("Value", "sum"), cost=("Unit cost", "first"))
items = items.sort_values("value", ascending=False)
items["cum_share"] = items["value"].cumsum() / items["value"].sum()
items["ABC"] = pd.cut(items["cum_share"], [0, 0.80, 0.95, 1.0], labels=["A", "B", "C"], include_lowest=True)
print(items.groupby("ABC", observed=True).agg(items=("value", "size"), value_share=("value", lambda v: v.sum() / items["value"].sum())).round(3))

Sorting by value and taking the cumulative share puts the most valuable items first; pd.cut assigns A to items within the first 80% of value, B to the next 15% and C to the rest. The usual Pareto shape appears: 59 of the 240 items are A and account for 80% of the money, while 109 C items — almost half the range — account for 5%. The thresholds are conventions, not laws — some businesses use 70/90, or classify by margin or by number of picks instead of value — so keep them as named settings.

Step 2 — XYZ by demand variability

XYZ classes measure how predictable demand is, using the coefficient of variation (CV) of monthly demand — the standard deviation divided by the mean:

Python
monthly = demand.pivot_table(index="SKU", columns="Month", values="Units", aggfunc="sum", fill_value=0)
stats = pd.DataFrame({"mean": monthly.mean(axis=1), "std": monthly.std(axis=1, ddof=0),
                      "months_with_demand": (monthly > 0).sum(axis=1)})
stats["cv"] = (stats["std"] / stats["mean"]).where(stats["mean"] > 0)
stats["XYZ"] = pd.cut(stats["cv"], [0, 0.5, 1.0, float("inf")], labels=["X", "Y", "Z"], include_lowest=True)
items = items.join(stats)
print(items["XYZ"].value_counts().sort_index().to_dict())
print(items.groupby("XYZ", observed=True)["cv"].agg(["min", "max"]).round(2))

A CV below 0.5 means monthly demand stays within about half its average — class X, predictable. Between 0.5 and 1.0 is Y — variable, often seasonal. Above 1.0 is Z — erratic, typically items that sell nothing most months and a lot occasionally. The pivot uses fill_value=0, because a month with no issues is real zero demand, which matters for the CV: leaving such months out would make lumpy items look steady. As with ABC, the cut-offs are conventions to adjust to your business.

The two dimensions of the classification ABC ranks items by annual consumption value, with A items making up the first 80 percent of value, B the next 15 and C the rest. XYZ ranks items by the coefficient of variation of monthly demand, with X below 0.5, Y between 0.5 and 1 and Z above 1. ABC — how much it matters A: first 80% of value B: next 15% C: last 5% (long tail) XYZ — how predictable X: CV < 0.5, steady Y: CV 0.5–1, variable/seasonal Z: CV > 1, erratic

Step 3 — Items with too little history

A CV from three months of data is meaningless, and items introduced recently would otherwise land in whatever class their few months happen to suggest. Flag them separately before building the matrix:

Python
first_month = monthly.gt(0).idxmax(axis=1)
items["first_month"] = first_month
items["new_item"] = items["first_month"] >= "2026-06"
items.loc[items["new_item"], "XYZ"] = None
print("new items held out of XYZ:", items.index[items["new_item"]].tolist())

Items whose first demand was in the last four months are marked as new and their XYZ class is cleared. They still have an ABC class — their value so far is real — but planning them by variability waits until there is enough history, typically six to twelve months. Listing them explicitly also stops them dragging down the X/Y/Z counts in a way nobody can explain. Look at the list rather than trusting it blindly: besides the eight genuinely new items, it catches SKU-0018, an established lumpy item whose first lump of the year happened to fall in June. A first-demand date cannot tell a late starter from an item that sells rarely, so where the item master holds a launch date, use that instead.

Step 4 — The ABC-XYZ matrix and a policy per cell

Cross-tabulate the two classes to get the nine-cell matrix, with item counts and each cell's share of total value:

Python
established = items[~items["new_item"]]
counts = pd.crosstab(established["ABC"], established["XYZ"])
value_share = (pd.crosstab(established["ABC"], established["XYZ"], values=established["value"], aggfunc="sum")
               .div(items["value"].sum()).mul(100).round(1))
print(counts.to_string())
print(value_share.to_string())

POLICY = {
    ("A", "X"): "automatic replenishment, low safety stock",
    ("A", "Y"): "forecast-driven, review monthly",
    ("A", "Z"): "manual planning, order to demand",
    ("B", "X"): "automatic replenishment",
    ("B", "Y"): "forecast-driven, moderate safety stock",
    ("B", "Z"): "review quarterly, consider make-to-order",
    ("C", "X"): "automatic, larger order quantities",
    ("C", "Y"): "simple reorder point",
    ("C", "Z"): "stock on demand or discontinue",
}
items["Class"] = items["ABC"].astype(str) + items["XYZ"].astype(str).replace("nan", "?")
items["Policy"] = [POLICY.get((a, x), "new item: review after 6 months") for a, x in zip(items["ABC"], items["XYZ"])]
print(items["Class"].value_counts().head(5).to_dict())

The count matrix shows how many items sit in each cell; the value-share matrix shows where the money is. Here 47 AX items carry 64% of all value, and 10 AZ items another 14.6%. Together the two matrices guide effort: AX items are valuable and predictable, so they can be replenished automatically with modest safety stock; AZ items are valuable and erratic — the cell that needs a planner's attention; CZ items tie up little money and are hard to forecast, so they are candidates to stock only on demand, or to discontinue. The policy table is the deliverable a warehouse team actually uses, and keeping it as data makes it easy to adapt. The crosstab technique itself is covered in Build a Crosstab from Excel Data with pandas.

The ABC-XYZ matrix and a policy per cell Rows A, B and C by value and columns X, Y and Z by predictability. AX items are replenished automatically, AY items forecast and reviewed monthly, AZ items planned manually. CX items get larger order quantities and CZ items are stocked on demand or discontinued. X steady Y variable Z erratic A high value auto, low safety stock forecast, monthly review manual planning B medium auto forecast, moderate stock quarterly review C low value auto, big batches reorder point on demand / discontinue Where the value sits in the matrix AX items hold 64 percent of total consumption value, AZ items 14.6 percent and BX items 11.1 percent. All other cells together hold the remaining tenth or so. AX (47 items) 64.0% AZ (10 items) 14.6% BX (53 items) 11.1% CX (70 items) 3.4% BZ (14 items) 2.7%

Step 5 — Write the matrix and the item list to Excel

The planner's workbook has the matrix with counts and value shares, and the full item list with class and policy:

Python
from openpyxl.formatting.rule import ColorScaleRule
from openpyxl.styles import Font

with pd.ExcelWriter("abc_xyz.xlsx", engine="openpyxl") as xw:
    counts.to_excel(xw, sheet_name="Matrix", startrow=1)
    value_share.to_excel(xw, sheet_name="Matrix", startrow=7)
    ws = xw.sheets["Matrix"]
    ws["A1"], ws["A7"] = "Items per cell", "Share of total value (%)"
    ws["A1"].font = ws["A7"].font = Font(bold=True)
    ws.conditional_formatting.add("B9:D11", ColorScaleRule(start_type="min", start_color="FFFFFF", end_type="max", end_color="4338CA"))
    out = items.reset_index()[["SKU", "ABC", "XYZ", "Class", "value", "cv", "months_with_demand", "new_item", "Policy"]]
    out.round({"value": 2, "cv": 2}).to_excel(xw, sheet_name="Items", index=False)
    xw.sheets["Items"].column_dimensions["I"].width = 44
print("value share by cell (%):", value_share.stack().sort_values(ascending=False).head(3).to_dict())

The value-share matrix gets a colour scale so the cells holding most of the money stand out; the Items sheet lists every SKU with its classes, the figures behind them and the recommended policy, ready to filter by class. Run the classification monthly or quarterly on a rolling twelve months: items move between classes as demand changes, and an item drifting from AX to AZ is an early warning worth reacting to.

Common pitfalls

  • ABC alone. Value without variability treats steady and erratic items the same.
  • Leaving zero-demand months out of the CV. Lumpy items then look steady; fill them with 0.
  • Classifying new items on a few months. Hold them out until there is enough history.
  • Thresholds treated as fixed rules. 80/95 and 0.5/1.0 are conventions; tune them.
  • A matrix with no policy. The point is what to do with each cell.

Conclusion

An ABC-XYZ classification combines value and predictability. ABC classes come from each item's cumulative share of annual consumption value; XYZ classes from the coefficient of variation of monthly demand, with zero-demand months included. Hold items with too little history out of XYZ, cross-tabulate the classes into a nine-cell matrix of counts and value shares, attach a stocking policy to each cell, and write the matrix and item list to Excel so planners can act on it — then rerun it regularly to catch items changing class.

Frequently asked questions

What is ABC-XYZ analysis? A classification of stock items by consumption value (ABC) and by demand variability (XYZ). Combining them in a 3×3 matrix shows which items are valuable and predictable and which are valuable but erratic.

How do I calculate XYZ classes in pandas? Pivot monthly demand per item with zeros for months without demand, compute the coefficient of variation as the standard deviation divided by the mean, and cut it at thresholds such as 0.5 and 1.0.

Why include months with zero demand in the CV? They are real demand of zero. Leaving them out makes items that sell only occasionally look steady.

How should new items be classified? Give them an ABC class from their value so far but hold back the XYZ class until they have six to twelve months of history.

More on pivots & summaries

More on pivots & summaries →