Standard Variance Analysis
Executive Summary

Executive Summary

Budget vs actual across 25 line items

Line Items
25
Total Budget
1023000
Total Actual
1059550
Total Variance
36550
Over Budget
14
Under Budget
8
Actuals came in at 1,059,550 against a Budget of 1,023,000 — an overall variance of 36,550 (+3.6%), over budget for the period. The single biggest dollar swing is Cloud Infrastructure, over by 43,000. Across 25 line items, 14 are over budget, 8 are under, and 3 landed on budget (within +/-1%). Direction alone is neutral here — over on a revenue line is good, over on a cost line is not.
Interpretation

Actuals exceeded budget by 36,550 (+3.6%) across 25 line items. Cloud Infrastructure is the single largest dollar swing at 43,000 over budget. Of 25 line items, 14 came in over budget, 8 under, and 3 on budget (within ±1%). The overall positive variance is neutral—favorable if driven by revenue outperformance, unfavorable if driven by cost overruns. The concentration of variance in a handful of items suggests targeted review of the top drivers will explain most of the budget miss.

Overview

Analysis Overview

Budget-vs-actual variance across 25 line items.

N Line Items25
Total Budget1023000
Total Actual1059550
Total Variance36550
Interpretation

Budget-vs-actual variance compares planned spending or revenue against actual performance for each line item. Variance is calculated as Actual minus Budget; positive values indicate over budget, negative under budget. The analysis covers 25 line items totaling 1,023,000 in budget and 1,059,550 in actual, yielding a net variance of 36,550. Lines are classified as over, under, or on budget (within ±1% of plan), then ranked by absolute dollar swing to identify where money moved most. Favorability is neutral—the same positive variance is favorable for revenue lines but unfavorable for cost lines. This report presents direction and magnitude only, leaving interpretation to your cost/revenue context.

Data Preparation

Data Quality

Rows, line items, zero-budget handling, and totals.

Initial Rows28
Final Rows28
Rows Removed0
N Line Items25
Zero Budget Items1
Interpretation

Data quality is strong: 28 rows were loaded and all 28 were retained across 25 line items after consolidating repeated rows per line item. No rows were excluded. One line item—Unplanned Data Migration—has a zero budget, making percentage variance undefined (shown as n/a); its actual amount of 15,000 is treated as the full variance. Total budget of 1,023,000 versus actual of 1,059,550 produces a net variance of 36,550. The single zero-budget line represents a special case requiring narrative explanation rather than percentage comparison, but does not distort the overall analysis or exclusion logic.

Visualization

Variance by Line Item

The largest budget-vs-actual swings, signed by direction.

Interpretation

Cloud Infrastructure leads all line items at +43,000 over budget, followed by Travel & Entertainment at −42,000 under budget and Legal Fees at +31,000 over budget. The chart displays the 20 largest signed swings, showing both directions equally: rightward bars represent over-budget lines, leftward bars under-budget. The range spans from Cloud Infrastructure at +43,000 to Contractor Labor at −28,000. Notably, several mid-sized lines cluster between ±12,000, indicating variance is not concentrated in a single item but distributed across multiple categories. Direction alone carries no favorability judgment; assessment depends on whether each line represents a cost or revenue category.

Data Table

Budget vs Actual Detail

Per-line-item budget, actual, variance, and status.

Line ItemBudgetActualVarianceVariance PCTStatus
Cloud Infrastructure50,00093,00043,000+86.0%over budget
Travel & Entertainment60,00018,000-42,000-70.0%under budget
Legal Fees20,00051,00031,000+155.0%over budget
Contractor Labor40,00012,000-28,000-70.0%under budget
Unplanned Data Migration015,00015,000n/aover budget
Software Licenses35,00047,50012,500+35.7%over budget
Advertising45,00033,000-12,000-26.7%under budget
Salaries - Sales150,000138,000-12,000-8.0%under budget
Professional Services30,00041,20011,200+37.3%over budget
Salaries - Engineering220,000231,00011,000+5.0%over budget
Recruiting18,00027,5009,500+52.8%over budget
Events & Conferences24,00015,200-8,800-36.7%under budget
R&D Materials38,00029,500-8,500-22.4%under budget
Equipment28,00036,4008,400+30.0%over budget
Training & Development15,0008,200-6,800-45.3%under budget
Marketing Programs40,00045,0005,000+12.5%over budget
Facilities Maintenance14,00017,6003,600+25.7%over budget
Shipping & Logistics26,00023,100-2,900-11.2%under budget
Customer Support Tools11,00013,7502,750+25.0%over budget
Subscriptions10,00011,9001,900+19.0%over budget
Telecom9,00010,3501,350+15.0%over budget
Bank & Card Fees6,0007,0501,050+17.5%over budget
Office Rent100,000100,500500+0.5%on budget
Insurance22,00021,800-200-0.9%on budget
Utilities12,00012,0000+0.0%on budget
Interpretation

The detailed table ranks all 25 line items by absolute dollar variance. Cloud Infrastructure (budget 50,000, actual 93,000, variance +43,000, +86.0%) tops the list. Travel & Entertainment (budget 60,000, actual 18,000, variance −42,000, −70.0%) shows the largest under-budget swing. Legal Fees (budget 20,000, actual 51,000, variance +31,000, +155.0%) shows the highest percentage variance at +155.0%. Contractor Labor (budget 40,000, actual 12,000, variance −28,000, −70.0%) is the second-largest under-budget line. Unplanned Data Migration, with zero budget and 15,000 actual, shows n/a for percentage variance but contributes 15,000 as a full variance. Status distribution: 14 over budget, 8 under, 3 on budget.

Data Table

Biggest Variance Drivers

Top line items by absolute dollar variance and their contribution.

Line ItemVarianceVariance PCTContribution PCT
Cloud Infrastructure43,000+86.0%15.4
Travel & Entertainment-42,000-70.0%15.1
Legal Fees31,000+155.0%11.1
Contractor Labor-28,000-70.0%10
Unplanned Data Migration15,000n/a5.4
Software Licenses12,500+35.7%4.5
Advertising-12,000-26.7%4.3
Salaries - Sales-12,000-8.0%4.3
Professional Services11,200+37.3%4
Salaries - Engineering11,000+5.0%3.9
Interpretation

The top 10 variance drivers account for 78% of total absolute variance, making them the priority for review. Cloud Infrastructure contributes 15.4% (43,000) and Travel & Entertainment contributes 15.1% (−42,000). Legal Fees adds 11.1% (31,000) and Contractor Labor adds 10% (−28,000). These four lines alone represent 51.6% of total absolute variance. Unplanned Data Migration (5.4%), Software Licenses (4.5%), Advertising (4.3%), Salaries - Sales (4.3%), Professional Services (4%), and Salaries - Engineering (3.9%) round out the top 10. The concentration in these 10 items means explaining their drivers will address most of the 36,550 net variance and identify systematic patterns in budget miss.

Methodology

Methodology

Statistical methodology and diagnostics for Budget vs Actual — Variance Analysis

Statistical Method

Budget vs Actual — Variance Analysis

Standard-library analysis: the core FP&A variance report straight from a budget-vs-actual table. Map a line item (account, category, or department), its budgeted amount, and its actual amount, and get the dollar variance and percentage variance for every line, each classified as over / under / on budget, the biggest swings ranked by dollar impact, and each line's contribution to the total variance. Whether a swing is favorable depends on whether the line is revenue-like or cost-like, so the report stays neutral and reports direction and size — you make the favorability call.

Data
N = 28 observations
Assumptions
  • One budget and one actual per line item (repeated rows are summed per line)
  • Variance is actual minus budget; percentage variance is against the budgeted amount
  • A line is on budget when it lands within plus or minus 1% of its budget
Limitations
  • Favorability is not inferred — a positive variance is favorable on a revenue line and unfavorable on a cost line, and the data cannot tell which it is
  • A zero-budget line has no percentage baseline, so its percentage variance is reported as n/a
  • This is period variance, not a forecast or a driver-based bridge — it explains what happened, not what will happen
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

Budget vs Actual — Variance Analysis

The core FP&A analysis: for every line item (account, category, or department) it compares the budgeted amount against the actual amount, computes the dollar variance and the percentage variance, classifies each line as over / under / on budget, and ranks the biggest swings by their absolute dollar impact and their share of total variance.

Why This Method?

Variance analysis is where planning meets reality: it turns a budget and a set of actuals into a prioritized list of exactly where money diverged from plan, by how much, and which line items drove it — the first thing every finance review opens with.

What This Analysis Covers

  • Per-line-item dollar variance and percentage variance
  • Over / under / on-budget classification (on budget within +/-1%)
  • The biggest swings ranked by absolute dollar impact
  • Each line item's contribution to total absolute variance

A Note On Favorability

Whether a variance is good or bad depends on whether the line is revenue-like or cost-like, which the data does not tell us. This module stays NEUTRAL — it reports over / under / on budget and leaves the favorable-vs-unfavorable call to the reader.

Standard Library

Platform standard-library module (LAT-1441): runs on ANY budget-vs-actual table via the semantic mapping {line_item, budget, actual}. All narrative is derived from the user's own column names and computed values.

suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))

Core Analysis Pipeline

Money formatter — plain grouped strings, never scientific notation.

Large dollar figures would otherwise serialize as 1.92e+05 in tables.

fmt_money <- function(x) {
  format(round(as.numeric(x), 0), big.mark = ",", scientific = FALSE, trim = TRUE)
}

Percentage formatter — signed, one decimal, "n/a" for no baseline.

fmt_pct <- function(x) {
  ifelse(is.na(x), "n/a", sprintf("%+.1f%%", x))
}

compute_shared <- function(df, params, col_map = list()) {
  # === SHARED EXPORTS ===
  #   initial_rows/final_rows/rows_removed  $ row accounting
  #   n_missing_item / n_missing_value      $ preprocessing counts
  #   li_name/budget_name/actual_name       $ humanized user column names
  #   agg               $ data.frame(line_item, budget, actual, variance,
  #                       variance_pct, abs_variance, contribution_pct,
  #                       status, zero_budget) — one row per line item,
  #                       ordered by |variance| desc
  #   n_items           $ integer — distinct line items
  #   n_zero_budget     $ integer — items with a zero budget baseline
  #   total_budget / total_actual / total_variance / total_variance_pct
  #   total_abs_variance $ numeric — sum of |variance| across items
  #   overall_status    $ "over budget" | "under budget" | "on budget"
  #   n_over / n_under / n_on            $ integer classification counts
  #   biggest_item / biggest_variance    $ the single largest dollar swing
  #   variance_by_item_df / variance_detail_df / top_drivers_df
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 1: Required semantic columns + humanized names

initial_rows <- nrow(df)
  li_name     <- humanize_semantic("line_item", col_map)[1]
  budget_name <- humanize_semantic("budget", col_map)[1]
  actual_name <- humanize_semantic("actual", col_map)[1]
  for (need in c("line_item", "budget", "actual")) {
    if (!need %in% names(df)) {
      stop(sprintf("Required column &#x27;%s' is not mapped.",
                   humanize_semantic(need, col_map)[1]))
    }
  }

Step 2: Line item — character, drop blank/missing

item <- trimws(as.character(df$line_item))
  keep_item <- !is.na(item) & item != ""
  n_missing_item <- sum(!keep_item)
  df <- df[keep_item, , drop = FALSE]
  item <- item[keep_item]

Step 3: Budget + actual — 95% numeric coercion rule

coerce_money <- function(v, cname) {
    if (is.numeric(v)) return(v)
    conv <- suppressWarnings(as.numeric(gsub("[,$ ]", "", as.character(v))))
    n_orig <- sum(!is.na(v) & trimws(as.character(v)) != "")
    if (n_orig == 0 || sum(!is.na(conv)) < 0.95 * n_orig) {
      stop(sprintf(
        "Column &#x27;%s' is not numeric enough to use as a money amount — fewer than 95%% of its values could be read as numbers.",
        cname))
    }
    conv
  }
  budget <- coerce_money(df$budget, budget_name)
  actual <- coerce_money(df$actual, actual_name)

Drop rows missing either budget or actual (a variance needs both)

keep_val <- !is.na(budget) & !is.na(actual)
  n_missing_value <- sum(!keep_val)
  item   <- item[keep_val]
  budget <- budget[keep_val]
  actual <- actual[keep_val]

  final_rows <- length(item)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 3) {
    stop(sprintf(
      "Only %d usable rows remain after cleaning &#x27;%s', '%s', and '%s' — need at least 3.",
      final_rows, li_name, budget_name, actual_name))
  }

Step 4: Aggregate (sum) by line item — handles repeated rows

raw <- data.frame(line_item = item, budget = budget, actual = actual,
                    stringsAsFactors = FALSE)
  agg <- raw %>%
    group_by(line_item) %>%
    summarise(budget = sum(budget), actual = sum(actual), .groups = "drop") %>%
    as.data.frame(stringsAsFactors = FALSE)

  n_items <- nrow(agg)
  if (n_items < 2) {
    stop(sprintf(
      "Only %d distinct line item found in &#x27;%s' — variance analysis needs at least 2.",
      n_items, li_name))
  }

Step 5: Variance, variance %, classification

variance % = variance / |budget|. A zero budget has no baseline, so the percentage is undefined (n/a) and the actual amount IS the variance.

agg$variance <- agg$actual - agg$budget
  agg$zero_budget <- agg$budget == 0
  agg$variance_pct <- ifelse(agg$zero_budget, NA_real_,
                             100 * agg$variance / abs(agg$budget))
  agg$abs_variance <- abs(agg$variance)

On budget = within +/-1% of budget; zero-budget items fall back to the dollar variance (only a zero swing counts as on budget).

classify <- function(variance, variance_pct, zero_budget) {
    if (zero_budget) {
      if (variance == 0) return("on budget")
      return(if (variance > 0) "over budget" else "under budget")
    }
    if (abs(variance_pct) <= 1) return("on budget")
    if (variance > 0) "over budget" else "under budget"
  }
  agg$status <- mapply(classify, agg$variance, agg$variance_pct, agg$zero_budget)

Step 6: Contribution to total absolute variance (guard divide-by-zero)

total_abs_variance <- sum(agg$abs_variance)
  agg$contribution_pct <- if (total_abs_variance > 0)
    round(100 * agg$abs_variance / total_abs_variance, 1) else 0

Order by absolute dollar impact — biggest swings first

agg <- agg[order(-agg$abs_variance), , drop = FALSE]
  rownames(agg) <- NULL

Step 7: Totals + headline facts

total_budget <- sum(agg$budget)
  total_actual <- sum(agg$actual)
  total_variance <- total_actual - total_budget
  total_variance_pct <- if (total_budget != 0)
    100 * total_variance / abs(total_budget) else NA_real_
  overall_status <- if (total_budget != 0 && !is.na(total_variance_pct) &&
                        abs(total_variance_pct) <= 1) {
    "on budget"
  } else if (total_variance > 0) "over budget"
  else if (total_variance < 0) "under budget" else "on budget"

  n_over  <- sum(agg$status == "over budget")
  n_under <- sum(agg$status == "under budget")
  n_on    <- sum(agg$status == "on budget")
  n_zero_budget <- sum(agg$zero_budget)

Biggest single swing — filter NA before which.max (LAT-1445 guard)

valid_idx <- which(!is.na(agg$abs_variance))
  top_i <- valid_idx[which.max(agg$abs_variance[valid_idx])]
  biggest_item <- agg$line_item[top_i]
  biggest_variance <- agg$variance[top_i]

Detail table — money as plain grouped strings (no scientific notation).

variance_detail_df <- data.frame(
    line_item    = agg$line_item,
    budget       = fmt_money(agg$budget),
    actual       = fmt_money(agg$actual),
    variance     = fmt_money(agg$variance),
    variance_pct = fmt_pct(agg$variance_pct),
    status       = agg$status,
    stringsAsFactors = FALSE
  )
  rownames(variance_detail_df) <- NULL

Biggest drivers — top 10 by absolute dollar impact.

drv <- head(agg, 10)
  top_drivers_df <- data.frame(
    line_item        = drv$line_item,
    variance         = fmt_money(drv$variance),
    variance_pct     = fmt_pct(drv$variance_pct),
    contribution_pct = drv$contribution_pct,
    stringsAsFactors = FALSE
  )
  rownames(top_drivers_df) <- NULL

  metrics <- list(
    `Line Items`     = n_items,
    `Total Budget`   = round(total_budget, 0),
    `Total Actual`   = round(total_actual, 0),
    `Total Variance` = round(total_variance, 0),
    `Over Budget`    = as.integer(n_over),
    `Under Budget`   = as.integer(n_under)
  )

  pct_txt <- if (!is.na(total_variance_pct))
    sprintf("%+.1f%%", total_variance_pct) else "n/a"
  json_output <- list(
    answer = paste0(
      "Actuals totalled ", fmt_money(total_actual), " against a budget of ",
      fmt_money(total_budget), " — an overall variance of ",
      fmt_money(total_variance), " (", pct_txt, "), i.e. ", overall_status,
      " for the period. The single biggest swing was ", biggest_item,
      " at ", fmt_money(biggest_variance), ". Of ",
      format(n_items, big.mark = ","), " line items, ", n_over,
      " came in over budget, ", n_under, " under, and ", n_on,
      " on budget(within +/-1%). Whether each swing is favorable depends on ",
      "whether the line is revenue-like or cost-like — read the direction ",
      "against your own chart of accounts."
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "variance_by_item",
        "variance_table", "biggest_drivers"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed,
    n_missing_item = n_missing_item, n_missing_value = n_missing_value,
    li_name = li_name, budget_name = budget_name, actual_name = actual_name,
    agg = agg, n_items = n_items, n_zero_budget = n_zero_budget,
    total_budget = total_budget, total_actual = total_actual,
    total_variance = total_variance, total_variance_pct = total_variance_pct,
    total_abs_variance = total_abs_variance, overall_status = overall_status,
    n_over = n_over, n_under = n_under, n_on = n_on,
    biggest_item = biggest_item, biggest_variance = biggest_variance,
    variance_by_item_df = variance_by_item_df,
    variance_detail_df = variance_detail_df,
    top_drivers_df = top_drivers_df,
    metrics = metrics, json_output = json_output
  )
}

# Card: tldr (tldr)
card_tldr <- function(shared, df, params) {
  pct_txt <- if (!is.na(shared$total_variance_pct))
    sprintf("%+.1f%%", shared$total_variance_pct) else "n/a"
  swing_dir <- if (shared$biggest_variance > 0) "over"
               else if (shared$biggest_variance < 0) "under" else "on"
  text <- paste0(
    "Actuals came in at ", fmt_money(shared$total_actual), " against a ",
    shared$budget_name, " of ", fmt_money(shared$total_budget), " — an ",
    "overall variance of ", fmt_money(shared$total_variance), " (", pct_txt,
    "), ", shared$overall_status, " for the period. The single biggest ",
    "dollar swing is ", shared$biggest_item, ", ", swing_dir, " by ",
    fmt_money(abs(shared$biggest_variance)), ". Across ",
    format(shared$n_items, big.mark = ","), " line items, ", shared$n_over,
    " are over budget, ", shared$n_under, " are under, and ", shared$n_on,
    " landed on budget(within +/-1%). Direction alone is neutral here — ",
    "over on a revenue line is good, over on a cost line is not."
  )
  list(
    title = "Executive Summary",
    description = paste0("Budget vs actual across ",
                         format(shared$n_items, big.mark = ","), " line items"),
    metrics = shared$metrics,
    text = text
  )
}
Your data has more stories to tell. Run any analysis on your own data — 60+ validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing