Executive Summary
Budget vs actual across 60 line items
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.
Analysis Overview
Budget-vs-actual variance across 60 line items.
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 Quality
Rows, line items, zero-budget handling, and totals.
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.
Variance by Line Item
The largest budget-vs-actual swings, signed by direction.
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.
Budget vs Actual Detail
Per-line-item budget, actual, variance, and status.
| Line Item | Budget | Actual | Variance | Variance PCT | Status |
|---|---|---|---|---|---|
| Water Utilities DWU | 645,128,387 | 608,401,562 | -36,726,825 | -5.7% | under budget |
| Debt Service BMS | 255,325,736 | 247,270,759 | -8,054,977 | -3.2% | under budget |
| Airport Operations AVI | 96,366,426 | 89,885,293 | -6,481,133 | -6.7% | under budget |
| Information Technology DSV | 67,182,087 | 60,888,631 | -6,293,456 | -9.4% | under budget |
| Non-Departmental | 57,085,112 | 52,286,487 | -4,798,625 | -8.4% | under budget |
| 9-1-1 System Operations | 16,292,461 | 11,732,453 | -4,560,008 | -28.0% | under budget |
| Code Compliance GF | 39,724,313 | 36,983,867 | -2,740,446 | -6.9% | under budget |
| Convention Center CCT | 93,838,892 | 91,359,437 | -2,479,455 | -2.6% | under budget |
| Equipment Services EBS | 54,009,134 | 51,539,927 | -2,469,207 | -4.6% | under budget |
| Storm Drainage Mgmt Operations | 53,016,846 | 50,759,297 | -2,257,549 | -4.3% | under budget |
| Dallas Fire Rescue GF | 239,567,341 | 237,873,679 | -1,693,662 | -0.7% | on budget |
| Sustainable Dev and Construction - Enterprise | 30,696,618 | 29,093,942 | -1,602,676 | -5.2% | under budget |
| Library GF | 30,033,677 | 29,006,031 | -1,027,646 | -3.4% | under budget |
| Police Department GF | 459,406,791 | 458,529,685 | -877,106 | -0.2% | on budget |
| Public Works GF | 5,910,853 | 5,278,601 | -632,252 | -10.7% | under budget |
| Human Resources GF | 4,788,424 | 4,307,370 | -481,054 | -10.0% | under budget |
| Planning and Urban Design GF | 3,782,181 | 3,338,061 | -444,120 | -11.7% | under budget |
| Mgmt Services - 311 Customer Service Center | 2,126,034 | 2,480,953 | 354,919 | +16.7% | over budget |
| Employee Benefits Administration | 1,126,137 | 783,907 | -342,230 | -30.4% | under budget |
| Street Services GF | 72,731,187 | 72,396,742 | -334,445 | -0.5% | on budget |
| Sustainable Dev and Construction GF | 1,127,742 | 798,169 | -329,573 | -29.2% | under budget |
| Office of Financial Services GF | 3,721,872 | 3,400,382 | -321,490 | -8.6% | under budget |
| Street Lighting | 17,525,192 | 17,211,708 | -313,484 | -1.8% | under budget |
| Court and Detention Services GF | 11,137,790 | 10,886,553 | -251,237 | -2.3% | under budget |
| Economic Development GF | 1,818,423 | 1,581,886 | -236,537 | -13.0% | under budget |
| Civil Service GF | 2,568,983 | 2,336,737 | -232,246 | -9.0% | under budget |
| Mgmt Services - Resiliency Office | 282,258 | 60,704 | -221,554 | -78.5% | under budget |
| Municipal Radio OCA | 2,054,549 | 1,853,356 | -201,193 | -9.8% | under budget |
| Office of Risk Management | 2,593,531 | 2,403,421 | -190,110 | -7.3% | under budget |
| Sanitation Operating Fund | 90,480,147 | 90,308,617 | -171,530 | -0.2% | on budget |
| Trinity Watershed Management GF | 1,126,320 | 976,193 | -150,127 | -13.3% | under budget |
| Mgmt Services - Public Information Office | 1,198,868 | 1,331,955 | 133,087 | +11.1% | over budget |
| City Controller's Office GF | 4,410,962 | 4,279,878 | -131,084 | -3.0% | under budget |
| City Secretary's Office GF | 2,004,606 | 1,887,652 | -116,954 | -5.8% | under budget |
| Radio Services DSV | 5,527,268 | 5,415,008 | -112,260 | -2.0% | under budget |
| Mgmt Services - Ethics and Diversity | 258,930 | 157,168 | -101,762 | -39.3% | under budget |
| Judiciary GF | 2,990,516 | 2,894,963 | -95,553 | -3.2% | under budget |
| Mgmt Services - Environmental Quaity | 802,207 | 722,433 | -79,774 | -9.9% | under budget |
| Mgmt Services - EMS Compliance | 528,321 | 448,774 | -79,547 | -15.1% | under budget |
| Elections | 753,724 | 683,403 | -70,321 | -9.3% | under budget |
| Wellness Program Fund | 429,603 | 366,505 | -63,098 | -14.7% | under budget |
| Mayor and City Council GF | 4,331,189 | 4,277,252 | -53,937 | -1.2% | under budget |
| City Attorney's Office GF | 15,686,107 | 15,636,648 | -49,459 | -0.3% | on budget |
| City Manager's Office GF | 1,972,061 | 1,931,890 | -40,171 | -2.0% | under budget |
| Mgmt Services - Internal Control Task Force | 433,830 | 400,149 | -33,681 | -7.8% | under budget |
| Express Business Center POM | 3,814,676 | 3,783,661 | -31,015 | -0.8% | on budget |
| City Auditor's Office GF | 2,954,057 | 2,928,617 | -25,440 | -0.9% | on budget |
| Building Services GF | 24,159,295 | 24,135,355 | -23,940 | -0.1% | on budget |
| Mgmt Services - Emergency Management | 643,618 | 630,747 | -12,871 | -2.0% | under budget |
| Mgmt Services - Council Agenda | 203,829 | 195,556 | -8,272 | -4.1% | under budget |
| Mgmt Services - Boards and Commissions | 85,762 | 82,151 | -3,611 | -4.2% | under budget |
| Office of Cultural Affairs GF | 17,701,062 | 17,698,595 | -2,467 | -0.0% | on budget |
| Mgmt Services - Fair Housing | 136,824 | 137,767 | 943 | +0.7% | on budget |
| Housing GF | 11,935,624 | 11,934,785 | -839 | -0.0% | on budget |
| Mgmt Services - Intergovernmental Services | 452,741 | 452,017 | -724 | -0.2% | on budget |
| Park and Recreation GF | 86,350,966 | 86,350,477 | -489 | -0.0% | on budget |
| Business Development & Procurement GF | 2,903,052 | 2,902,723 | -329 | -0.0% | on budget |
| Mgmt Services - Strategic Initiatives | 941,148 | 940,899 | -249 | -0.0% | on budget |
| Jail Contract | 7,557,391 | 7,557,391 | 0 | -0.0% | on budget |
| Reserves & Transfers | 4,622,320 | 4,622,320 | 0 | +0.0% | on budget |
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.
Biggest Variance Drivers
Top line items by absolute dollar variance and their contribution.
| Line Item | Variance | Variance PCT | Contribution 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 |
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.
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
)
}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