HomeCourseModule 13 › User story: budget vs actual variance

User story: budget vs actual variance

Module 13 · Scenario: Finance & Accounting10 min readIntermediate

What you'll learn

  • Join budget and actual data
  • Compute variance and percent variance
  • Flag and sort by exposure

The story

Priya needs a one-page variance report for the CFO. Every department, every GL account, budget vs actual, with the worst variances at the top.

The script

import pandas as pd

budget = pd.read_excel("budget_2026.xlsx")    # cols: dept, account, month, budget
actual = pd.read_excel("actual_2026.xlsx")    # cols: dept, account, month, actual

merged = budget.merge(actual,
                      on=["dept", "account", "month"],
                      how="outer").fillna(0)

merged["variance"]     = merged["actual"] - merged["budget"]
merged["variance_pct"] = (merged["variance"] / merged["budget"].replace(0, pd.NA)) * 100

# Flag the worrying lines
def flag(row):
    if abs(row["variance"]) > 50_000:           return "MATERIAL"
    if abs(row["variance_pct"] or 0) > 20:      return "HIGH-PCT"
    return ""
merged["flag"] = merged.apply(flag, axis=1)

# Sort: flagged first, then by absolute variance
report = (merged.assign(abs_var = merged["variance"].abs())
                 .sort_values(["flag", "abs_var"], ascending=[True, False])
                 .drop(columns="abs_var"))

report.to_excel("variance_april.xlsx", index=False)

Make the output executive-friendly

from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill

wb = load_workbook("variance_april.xlsx")
ws = wb.active

# Headers: bold, green
for cell in ws[1]:
    cell.font = Font(bold=True, color="FFFFFF")
    cell.fill = PatternFill("solid", fgColor="217346")

# Highlight MATERIAL rows
red = PatternFill("solid", fgColor="FEE2E2")
for row in ws.iter_rows(min_row=2):
    if row[ws.max_column - 1].value == "MATERIAL":
        for cell in row:
            cell.fill = red

# Currency formats
for col in "DEFG":
    for cell in ws[col][1:]:
        cell.number_format = '"$"#,##0;[Red]("$"#,##0)'

# Freeze pane + widths
ws.freeze_panes = "A2"
for col, w in zip("ABCDEFGH", [10, 12, 10, 14, 14, 14, 10, 14]):
    ws.column_dimensions[col].width = w

wb.save("variance_april.xlsx")

Walkthrough for video

Open both source files

"Budget. Actual. Same shape but different numbers."

One merge, one subtraction

"merge(on=[dept, account, month]) joins them. Two assignments compute variance and percent variance."

Apply the flag

"Anything over $50k absolute or 20% relative gets a label."

Save and style

"Save with pandas, restyle with openpyxl. Red rows for the lines that matter."

Key takeaways

  • Variance reports are merge + arithmetic + sort.
  • Always flag both absolute and percentage variance — a 50% variance on $200 doesn't matter.
  • Stylesheet at the end makes it CFO-ready.

Add a sparkline column

Extend the report: add a "trend" column with the last six months of actuals as a comma-separated list. (Hint: groupby dept+account, take last 6 months, agg into a list.)

📹 Video walkthrough
A video walkthrough of this lesson will be embedded here. Until then, the written walkthrough above mirrors what the video will cover step-for-step.