Standard Variance Analysis
Executive Summary

Executive Summary

Budget vs actual across 60 line items

Line Items
60
Total Budget
2568366001
Total Actual
2480801152
Total Variance
-87564849
Over Budget
2
Under Budget
41
Actuals came in at 2,480,801,152 against a budget of 2,568,366,001 — an overall variance of -87,564,849 (-3.4%), under budget for the period. The single biggest dollar swing is Water Utilities DWU, under by 36,726,825. Across 60 line items, 2 are over budget, 41 are under, and 17 landed on budget (within +/-1%). Direction alone is neutral here — over on a revenue line is good, over on a cost line is not.
What this means

Actuals came in at 2,480,801,152 against a budget of 2,568,366,001—an overall variance of -87,564,849, or -3.4% under budget. The single biggest dollar swing is Water Utilities DWU, under by 36,726,825. Of 60 line items, 2 came in over budget, 41 under, and 17 on budget (within ±1%). The direction alone is neutral; over on a revenue line is favorable, over on a cost line is not.

Overview

Analysis Overview

Budget-vs-actual variance across 60 line items.

N Line Items60
Total Budget2568366001
Total Actual2480801152
Total Variance-87564849
What this means

This analysis compares actual spending against budget across 60 line items for the fiscal year. Each line's variance is the actual minus budget; a negative variance means under budget, positive means over. The report classifies each line as over, under, or on budget (within ±1% of plan) and ranks them by the size of the dollar swing to show where money actually moved relative to plan. Favorability depends on the line type — a positive variance is favorable for revenue but unfavorable for cost — so the analysis reports direction and magnitude neutrally. The total budget was 2,568,366,001 against actuals of 2,480,801,152, yielding a net variance of -87,564,849.

Data Preparation

Data Quality

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

Initial Rows60
Final Rows60
Rows Removed0
N Line Items60
Zero Budget Items0
What this means

All 60 rows were successfully loaded and used; no rows were excluded and no line items needed summing. The dataset contains no zero-budget lines, so all 60 items have both budget and actual figures with valid percentage variance calculations. Totals reconcile: budget 2,568,366,001, actual 2,480,801,152, variance -87,564,849. Data quality is complete with no missing or excluded records.

Visualization

Variance by Line Item

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

What this means

Water Utilities DWU dominates the variance landscape, running -36,726,825 under budget—the largest signed swing among all 60 items. The next two largest underruns are Debt Service BMS at -8,054,977 and Airport Operations AVI at -6,481,133. Only one line item appears on the right side of the chart: Mgmt Services - 311 Customer Service Center at +354,918.61 over budget. The remaining 19 items shown all run under, with swings ranging from -6,293,456 (Information Technology DSV) down to -334,445 (Street Services GF). Bars do not overlap meaningfully, so each represents a distinct magnitude of variance.

Data Table

Budget vs Actual Detail

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

Line ItemBudgetActualVarianceVariance PCTStatus
Water Utilities DWU645,128,387608,401,562-36,726,825-5.7%under budget
Debt Service BMS255,325,736247,270,759-8,054,977-3.2%under budget
Airport Operations AVI96,366,42689,885,293-6,481,133-6.7%under budget
Information Technology DSV67,182,08760,888,631-6,293,456-9.4%under budget
Non-Departmental57,085,11252,286,487-4,798,625-8.4%under budget
9-1-1 System Operations16,292,46111,732,453-4,560,008-28.0%under budget
Code Compliance GF39,724,31336,983,867-2,740,446-6.9%under budget
Convention Center CCT93,838,89291,359,437-2,479,455-2.6%under budget
Equipment Services EBS54,009,13451,539,927-2,469,207-4.6%under budget
Storm Drainage Mgmt Operations53,016,84650,759,297-2,257,549-4.3%under budget
Dallas Fire Rescue GF239,567,341237,873,679-1,693,662-0.7%on budget
Sustainable Dev and Construction - Enterprise30,696,61829,093,942-1,602,676-5.2%under budget
Library GF30,033,67729,006,031-1,027,646-3.4%under budget
Police Department GF459,406,791458,529,685-877,106-0.2%on budget
Public Works GF5,910,8535,278,601-632,252-10.7%under budget
Human Resources GF4,788,4244,307,370-481,054-10.0%under budget
Planning and Urban Design GF3,782,1813,338,061-444,120-11.7%under budget
Mgmt Services - 311 Customer Service Center2,126,0342,480,953354,919+16.7%over budget
Employee Benefits Administration1,126,137783,907-342,230-30.4%under budget
Street Services GF72,731,18772,396,742-334,445-0.5%on budget
Sustainable Dev and Construction GF1,127,742798,169-329,573-29.2%under budget
Office of Financial Services GF3,721,8723,400,382-321,490-8.6%under budget
Street Lighting17,525,19217,211,708-313,484-1.8%under budget
Court and Detention Services GF11,137,79010,886,553-251,237-2.3%under budget
Economic Development GF1,818,4231,581,886-236,537-13.0%under budget
Civil Service GF2,568,9832,336,737-232,246-9.0%under budget
Mgmt Services - Resiliency Office282,25860,704-221,554-78.5%under budget
Municipal Radio OCA2,054,5491,853,356-201,193-9.8%under budget
Office of Risk Management2,593,5312,403,421-190,110-7.3%under budget
Sanitation Operating Fund90,480,14790,308,617-171,530-0.2%on budget
Trinity Watershed Management GF1,126,320976,193-150,127-13.3%under budget
Mgmt Services - Public Information Office1,198,8681,331,955133,087+11.1%over budget
City Controller's Office GF4,410,9624,279,878-131,084-3.0%under budget
City Secretary's Office GF2,004,6061,887,652-116,954-5.8%under budget
Radio Services DSV5,527,2685,415,008-112,260-2.0%under budget
Mgmt Services - Ethics and Diversity258,930157,168-101,762-39.3%under budget
Judiciary GF2,990,5162,894,963-95,553-3.2%under budget
Mgmt Services - Environmental Quaity802,207722,433-79,774-9.9%under budget
Mgmt Services - EMS Compliance528,321448,774-79,547-15.1%under budget
Elections753,724683,403-70,321-9.3%under budget
Wellness Program Fund429,603366,505-63,098-14.7%under budget
Mayor and City Council GF4,331,1894,277,252-53,937-1.2%under budget
City Attorney's Office GF15,686,10715,636,648-49,459-0.3%on budget
City Manager's Office GF1,972,0611,931,890-40,171-2.0%under budget
Mgmt Services - Internal Control Task Force433,830400,149-33,681-7.8%under budget
Express Business Center POM3,814,6763,783,661-31,015-0.8%on budget
City Auditor's Office GF2,954,0572,928,617-25,440-0.9%on budget
Building Services GF24,159,29524,135,355-23,940-0.1%on budget
Mgmt Services - Emergency Management643,618630,747-12,871-2.0%under budget
Mgmt Services - Council Agenda203,829195,556-8,272-4.1%under budget
Mgmt Services - Boards and Commissions85,76282,151-3,611-4.2%under budget
Office of Cultural Affairs GF17,701,06217,698,595-2,467-0.0%on budget
Mgmt Services - Fair Housing136,824137,767943+0.7%on budget
Housing GF11,935,62411,934,785-839-0.0%on budget
Mgmt Services - Intergovernmental Services452,741452,017-724-0.2%on budget
Park and Recreation GF86,350,96686,350,477-489-0.0%on budget
Business Development & Procurement GF2,903,0522,902,723-329-0.0%on budget
Mgmt Services - Strategic Initiatives941,148940,899-249-0.0%on budget
Jail Contract7,557,3917,557,3910-0.0%on budget
Reserves & Transfers4,622,3204,622,3200+0.0%on budget
What this means

The short answer

Of the 60 line items tracked, 41 came in under budget and only 2 came in over. The largest single variance is Water Utilities DWU at -36,726,825, followed by Debt Service BMS at -8,054,977. Most departments spent less than planned, with variances ranging from small (Police Department GF at -0.2%) to substantial (9-1-1 System Operations at -28.0%).

The detail

The variance table shows all 60 line items ordered by dollar swing. Water Utilities DWU underran by -36,726,825 (-5.7%). Debt Service BMS came in -8,054,977 under (-3.2%). Airport Operations AVI was -6,481,133 under (-6.7%), and Information Technology DSV was -6,293,456 under (-9.4%). The two over-budget lines are not named in the excerpt, but the summary states 41 under and 2 over. Large departments like Dallas Fire Rescue GF (-1,693,662, -0.7%) and Police Department GF (-877,106, -0.2%) ran tight to plan. Smaller variances in absolute dollars but larger in percentage include Public Works GF at -10.7%.

What this can't tell you

The data does not explain why individual line items underran or overran. Consider drilling into specific departments' spending patterns and comparing actual monthly burn rates against budget to identify whether underruns reflect delayed hiring, deferred projects, or genuine efficiency gains.

Data Table

Biggest Variance Drivers

Top line items by absolute dollar variance and their contribution.

Line ItemVarianceVariance PCTContribution PCT
Water Utilities DWU-36,726,825-5.7%41.5
Debt Service BMS-8,054,977-3.2%9.1
Airport Operations AVI-6,481,133-6.7%7.3
Information Technology DSV-6,293,456-9.4%7.1
Non-Departmental-4,798,625-8.4%5.4
9-1-1 System Operations-4,560,008-28.0%5.2
Code Compliance GF-2,740,446-6.9%3.1
Convention Center CCT-2,479,455-2.6%2.8
Equipment Services EBS-2,469,207-4.6%2.8
Storm Drainage Mgmt Operations-2,257,549-4.3%2.5
What this means

Water Utilities DWU is the dominant variance driver, accounting for 41.5% of total absolute variance despite a modest -5.7% miss on its own budget. The next four—Debt Service BMS (9.1%), Airport Operations AVI (7.3%), Information Technology DSV (7.1%), and Non-Departmental (5.4%)—together add another 28.9%. Just the top 10 line items account for 86.8% of the total absolute variance swing, meaning the fiscal year's -87,564,849 miss is concentrated in a short list. 9-1-1 System Operations, though smaller in absolute dollars at -4,560,008, still ranks sixth by contribution at 5.2%, driven by its steep -28.0% percentage variance. These 10 lines are the focus points for explaining where the budget miss occurred.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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 , R modules you own and can re-run, interactive reports, AI insights, and PDF export. 14 days of full access when you finish onboarding.
Try Free — No SignupSign Up Free

Your turn

Bring your own data and the question you actually need answered.

CympleData Scientist Send me your data and question, I’ll send you the analytics. ds@mcpanalytics.ai

Cite this analysis

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