How-to
Automating Reporting WorkflowsDeep dive

Build a Price-Volume-Mix Analysis

Explain why revenue changed between two periods by splitting the change into price, volume and mix effects with pandas: per-product price and quantity, the standard PVM formulas, separate lines for new and discontinued products, a check that the effects add up exactly to the change, a breakdown by product, and an Excel report with a bridge table.

"Revenue is up 20% on last year" invites the obvious follow-up: because we sold more, because we charged more, or because we sold more of the expensive products? A price-volume-mix analysis answers it by splitting the change in revenue into three effects that add up exactly to the total. The volume effect is what the change would have been if only the number of units had changed, at last year's average price. The mix effect is what moving sales towards more or less expensive products contributed. The price effect is what charging different prices for the same products contributed. Finance teams present the result as a bridge from last year's revenue to this year's. This page builds the analysis from two periods of sales lines with pandas, handles products that are new this year or no longer sold, proves the effects reconcile to the penny, breaks each effect down by product and writes an Excel report. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl

The input is a year of sales lines for 2025 and 2026: product, quantity and revenue per line. Four products sold in 2025; in 2026 one of them, Legacy, was discontinued and a new one, Eco, was launched — the situation that most simple PVM templates handle badly.

Step 1 — Quantity, revenue and price per product and period

Python
import pandas as pd

lines = pd.read_excel("sales_lines.xlsx")
pv = lines.pivot_table(index="Product", columns="Year", values=["Qty", "Revenue"], aggfunc="sum", fill_value=0)
Q0, Q1 = pv["Qty"][2025], pv["Qty"][2026]
R0, R1 = pv["Revenue"][2025], pv["Revenue"][2026]
P0, P1 = (R0 / Q0).where(Q0 > 0), (R1 / Q1).where(Q1 > 0)
summary = pd.DataFrame({"Qty 2025": Q0, "Qty 2026": Q1, "Price 2025": P0.round(2), "Price 2026": P1.round(2),
                        "Revenue 2025": R0, "Revenue 2026": R1})
print(summary.to_string())
print(f"revenue {R0.sum():,.2f} -> {R1.sum():,.2f} ({R1.sum() - R0.sum():+,.2f}, {R1.sum() / R0.sum() - 1:+.1%})")

Price per product is revenue divided by quantity for the period — the average price actually achieved, which already includes discounts, so the price effect will capture discounting as well as list-price changes. A product with no sales in a period has no price, and where(Q > 0) leaves it missing rather than dividing by zero. Revenue grew from 191,907.68 to 229,940.46, up 38,032.78 or almost 20%; the question is what that is made of.

Step 2 — The three effects for continuing products

Separate the products sold in both years from those in only one, and apply the standard formulas to the continuing ones:

Python
both = Q0.gt(0) & Q1.gt(0)
q0, q1, p0, p1 = Q0[both], Q1[both], P0[both], P1[both]
avg_p0 = (q0 * p0).sum() / q0.sum()                         # last year's average price per unit
share0, share1 = q0 / q0.sum(), q1 / q1.sum()

volume = (q1.sum() - q0.sum()) * avg_p0
mix = (q1.sum() * (share1 - share0) * p0)
price = (p1 - p0) * q1
new = R1[Q0.eq(0) & Q1.gt(0)].sum()
lost = -R0[Q0.gt(0) & Q1.eq(0)].sum()

bridge = pd.Series({"Revenue 2025": R0.sum(), "Volume": volume, "Mix": mix.sum(), "Price": price.sum(),
                    "New products": new, "Discontinued": lost, "Revenue 2026": R1.sum()})
print(bridge.round(2).to_string())
explained = bridge["Revenue 2025"] + bridge[["Volume", "Mix", "Price", "New products", "Discontinued"]].sum()
print(f"reconciles: {abs(explained - R1.sum()) < 0.005} (difference {explained - R1.sum():.6f})")

The volume effect values the change in total units of continuing products at last year's average price per unit: if nothing but the number of units had changed, this is what revenue would have moved by. The mix effect values each product's change in share of units at its own prior price — selling a larger share of Pro and a smaller share of Standard raises revenue even at the same total volume and the same prices. The price effect is the change in each product's price times this year's quantity. For continuing products these three add up exactly to the change in their revenue, which is the property that makes PVM trustworthy; Eco and Legacy cannot be split, because one has no prior price and the other no current quantity, so they appear as their own lines. The final check proves the whole bridge reconciles. Of the 38,033 increase, 25,058 came from selling more units, 12,594 from a richer mix, 5,341 from higher realised prices and 6,130 from the new Eco range, offset by 11,090 of Legacy sales that disappeared.

The revenue bridge from 2025 to 2026 Revenue rose from 191,908 to 229,940. Volume added 25,058, mix 12,594, price 5,341 and the new Eco range 6,130, while the discontinued Legacy range removed 11,090. Volume +25,058 Mix +12,594 Price +5,341 New products +6,130 Discontinued −11,090 The price, volume and mix formulas Volume is the change in total units times last year's average price. Mix is current total units times the change in each product's share times its prior price, summed. Price is the change in each product's price times its current quantity, summed. New and discontinued products are shown as their own revenue. formula what it measures Volume (ΣQ1 − ΣQ0) × avg P0 more or fewer units Mix Σ ΣQ1 × (s1 − s0) × P0 shift to dearer or cheaper products Price Σ (P1 − P0) × Q1 price and discount changes New / discontinued R1 or −R0 products in one year only

Step 3 — Which products drove each effect

Python
by_product = pd.DataFrame({"Volume share": volume * share0, "Mix": mix, "Price": price},
                          index=Q0.index).fillna(0)                   # volume spread by prior unit share, for display
by_product["New / discontinued"] = pd.Series({"Eco": new, "Legacy": lost}).reindex(by_product.index, fill_value=0)
by_product["Total"] = by_product.sum(axis=1)
by_product = by_product.round(2)
print(by_product.to_string())

The product view shows where each effect came from. Its Total column adds up to the overall change, but an individual product's total is not that product's own change in revenue — Plus shows 21,823 against a revenue increase of 23,457 — because the volume figure is shared out by prior share for display rather than measured product by product. Mix is positive for products whose share of units rose and that are priced above average — Plus and Pro here — and negative for Standard, whose share fell. The price effect shows which products' realised prices moved: Standard and Plus up, Pro slightly down, perhaps through discounting to win volume. Volume is a single figure in the method above; the product column spreads it by each product's prior share purely so the table sums, and is labelled accordingly. Analysing margins rather than revenue uses the same structure with unit margin in place of unit price, as in Build a Product Margin Report.

Step 4 — The bridge in Excel

Python
from openpyxl import load_workbook
from openpyxl.styles import Font

path = "pvm_2026_vs_2025.xlsx"
table = bridge.round(2).rename("Amount").to_frame()
with pd.ExcelWriter(path, engine="openpyxl") as xw:
    table.to_excel(xw, sheet_name="Bridge", index_label="Step")
    by_product.to_excel(xw, sheet_name="By product")
    summary.to_excel(xw, sheet_name="Price and quantity")
wb = load_workbook(path)
ws = wb["Bridge"]
n = ws.max_row
ws.cell(n + 2, 1, "Check: 2025 + effects - 2026").font = Font(italic=True)
ws.cell(n + 2, 2, f"=B2+SUM(B3:B{n - 1})-B{n}")
for r in range(2, n + 3):
    ws.cell(r, 2).number_format = "#,##0.00;[Red]-#,##0.00;-"
for r in (2, n):
    ws.cell(r, 1).font = ws.cell(r, 2).font = Font(bold=True)
ws.column_dimensions["A"].width = 30
wb.save(path)
print(table.to_string())

The Bridge sheet runs from 2025 revenue through each effect to 2026 revenue, with a check formula that should show a dash — zero — beneath it; the other sheets hold the product detail and the underlying prices and quantities, so every number can be traced. Drawn as a waterfall chart, the bridge is the standard way to present PVM in a board pack; Build a Waterfall Chart in Excel with Python shows how to build one.

From sales lines to a PVM bridge Aggregate quantity and revenue by product and year, derive average prices, separate continuing, new and discontinued products, compute volume, mix and price for the continuing ones, check the bridge reconciles, and write the bridge and product detail to Excel. Aggregate qty and revenue by year Average prices revenue ÷ qty Split products continuing, new, lost Volume, mix, price standard formulas Reconcile + write bridge to Excel

Choices that change the numbers

PVM looks objective, but several choices move amounts between the effects, and readers should know which were made. Measuring price at current quantities and mix at prior prices, as here, is one convention; using prior quantities for price leaves an extra price × volume interaction term that some teams show separately. The level at which mix is measured matters too: mix between product families and mix between individual SKUs give different answers, and the finer the level, the more of the change is attributed to mix. Units must be comparable across products for the volume and mix effects to mean anything — pieces of very different products, or units versus kilograms, are better analysed within families. Whatever the choices, keep them fixed from period to period and state them on the report, so a change in the effects reflects the business rather than the method.

Common pitfalls

  • Effects that do not add up. Use formulas that reconcile exactly, and check them.
  • Forcing new and discontinued products into price or mix. Show them as their own lines.
  • List prices instead of realised prices. Use revenue ÷ quantity so discounts count.
  • Mixing incomparable units. Analyse within families when units differ.
  • Changing conventions between periods. State the method and keep it.

Conclusion

A price-volume-mix analysis splits the change in revenue into volume — the change in total units at last year's average price — mix — shifts in product share at prior prices — and price — changes in realised price on current quantities — with new and discontinued products shown separately. Compute average prices from revenue and quantity, separate continuing products, apply the three formulas, prove the bridge reconciles, break the effects down by product and write the bridge with its check to Excel.

Frequently asked questions

How do I calculate price, volume and mix effects in pandas? Aggregate quantity and revenue by product for both periods, compute average prices, then volume = change in total units × prior average price, mix = Σ current total units × change in share × prior price, and price = Σ change in price × current quantity.

Why do my price, volume and mix effects not add up to the revenue change? The formulas must use consistent quantities and prices; with prior average price for volume, prior prices for mix and current quantities for price, the three add up exactly for continuing products.

How do I handle new and discontinued products in a PVM analysis? Show their revenue as separate new-product and discontinued-product lines, because they have no price in one of the periods.

Can PVM be done on margin instead of revenue? Yes. Use unit margin instead of unit price in the same formulas to explain the change in gross margin.

More on report layouts & templates

More on report layouts & templates →