Executive Summary
Budget vs actual across 25 line items
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.
Analysis Overview
Budget-vs-actual variance across 25 line items.
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 Quality
Rows, line items, zero-budget handling, and totals.
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.
Variance by Line Item
The largest budget-vs-actual swings, signed by direction.
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.
Budget vs Actual Detail
Per-line-item budget, actual, variance, and status.
| Line Item | Budget | Actual | Variance | Variance PCT | Status |
|---|---|---|---|---|---|
| Cloud Infrastructure | 50,000 | 93,000 | 43,000 | +86.0% | over budget |
| Travel & Entertainment | 60,000 | 18,000 | -42,000 | -70.0% | under budget |
| Legal Fees | 20,000 | 51,000 | 31,000 | +155.0% | over budget |
| Contractor Labor | 40,000 | 12,000 | -28,000 | -70.0% | under budget |
| Unplanned Data Migration | 0 | 15,000 | 15,000 | n/a | over budget |
| Software Licenses | 35,000 | 47,500 | 12,500 | +35.7% | over budget |
| Advertising | 45,000 | 33,000 | -12,000 | -26.7% | under budget |
| Salaries - Sales | 150,000 | 138,000 | -12,000 | -8.0% | under budget |
| Professional Services | 30,000 | 41,200 | 11,200 | +37.3% | over budget |
| Salaries - Engineering | 220,000 | 231,000 | 11,000 | +5.0% | over budget |
| Recruiting | 18,000 | 27,500 | 9,500 | +52.8% | over budget |
| Events & Conferences | 24,000 | 15,200 | -8,800 | -36.7% | under budget |
| R&D Materials | 38,000 | 29,500 | -8,500 | -22.4% | under budget |
| Equipment | 28,000 | 36,400 | 8,400 | +30.0% | over budget |
| Training & Development | 15,000 | 8,200 | -6,800 | -45.3% | under budget |
| Marketing Programs | 40,000 | 45,000 | 5,000 | +12.5% | over budget |
| Facilities Maintenance | 14,000 | 17,600 | 3,600 | +25.7% | over budget |
| Shipping & Logistics | 26,000 | 23,100 | -2,900 | -11.2% | under budget |
| Customer Support Tools | 11,000 | 13,750 | 2,750 | +25.0% | over budget |
| Subscriptions | 10,000 | 11,900 | 1,900 | +19.0% | over budget |
| Telecom | 9,000 | 10,350 | 1,350 | +15.0% | over budget |
| Bank & Card Fees | 6,000 | 7,050 | 1,050 | +17.5% | over budget |
| Office Rent | 100,000 | 100,500 | 500 | +0.5% | on budget |
| Insurance | 22,000 | 21,800 | -200 | -0.9% | on budget |
| Utilities | 12,000 | 12,000 | 0 | +0.0% | on budget |
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.
Biggest Variance Drivers
Top line items by absolute dollar variance and their contribution.
| Line Item | Variance | Variance PCT | Contribution PCT |
|---|---|---|---|
| Cloud Infrastructure | 43,000 | +86.0% | 15.4 |
| Travel & Entertainment | -42,000 | -70.0% | 15.1 |
| Legal Fees | 31,000 | +155.0% | 11.1 |
| Contractor Labor | -28,000 | -70.0% | 10 |
| Unplanned Data Migration | 15,000 | n/a | 5.4 |
| Software Licenses | 12,500 | +35.7% | 4.5 |
| Advertising | -12,000 | -26.7% | 4.3 |
| Salaries - Sales | -12,000 | -8.0% | 4.3 |
| Professional Services | 11,200 | +37.3% | 4 |
| Salaries - Engineering | 11,000 | +5.0% | 3.9 |
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
Statistical methodology and diagnostics for Budget vs Actual — Variance Analysis
Statistical Method
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.
- 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
- 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
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 '%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 '%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 '%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 '%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 0Order by absolute dollar impact — biggest swings first
agg <- agg[order(-agg$abs_variance), , drop = FALSE]
rownames(agg) <- NULLStep 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) <- NULLBiggest 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
)
}