How to build a budget vs actual report (variance analysis)
Max Musing
Max MusingFounder and CEO of Basedash
· July 8, 2026

Max Musing
Max MusingFounder and CEO of Basedash
· July 8, 2026

A budget vs actual report compares what you planned to spend or earn against what actually happened, and calculates the gap (the variance) for each line. The whole point is to answer one question fast: where did reality diverge from the plan, and does that gap matter enough to act on?
This guide walks through how to build that report from the underlying data instead of rebuilding it by hand every month. It covers where budget and actuals live, how to join them at the same grain, the SQL to compute variance, how to flag the variances worth a conversation, and how to automate the whole thing. It is written for finance leads, FP&A analysts, and operators who own a monthly numbers review and are tired of copy-pasting a plan into a spreadsheet next to the accounting export.
A budget vs actual report (sometimes called a plan vs actual or a variance report) sits between accounting and decision-making. Accounting tells you what happened. The variance report tells you whether what happened matches what you committed to, and by how much.
For each line, it shows four numbers: the budgeted amount, the actual amount, the variance (actual minus budget), and the variance as a percentage of budget. Finance teams run it monthly or quarterly against a plan set at the start of the year or re-forecast during it.
The report has a narrow job. It is not a forecasting model, a cash flow statement, or a full FP&A planning tool. It answers: are we on plan, and where are we off? The follow-up work (re-forecasting, reallocating budget, asking a department head what happened) starts from its output.
The variance formula is trivial. The reason variance reporting stays a manual, error-prone chore is that the two sides of the comparison almost never live together.
So a real variance report is a join problem. You need both sides described with the same account names, the same time periods, and the same level of detail, then joined on those keys. Most of the manual pain (mismatched account labels, a plan that is monthly while actuals are daily, a department that got renamed) is data-shape pain, not math.
Get the shapes to line up once and the report becomes a query you can rerun forever.
Your actuals are already query-able if they land in a database or warehouse. The plan usually is not, because it lives in a sheet.
You have three options, in rough order of effort:
budget table in your warehouse or production database with columns like period, account, department, and budget_amount. This is the cleanest long-term setup and lets you join everything in SQL.Whatever you choose, the plan needs the same account and period vocabulary as your actuals. This is the single highest-leverage thing you can do. If your general ledger calls it 6100 Software Subscriptions and your budget sheet calls it SaaS tools, build a small mapping table once so the join is exact and stays exact.
“Grain” means the level of detail a row represents. Your plan is probably one row per account per month. So your actuals query needs to roll up to exactly that: one row per account per month.
If actuals live in a warehouse table of transactions, that is a group by:
select
date_trunc('month', posted_at)::date as period,
account,
sum(amount) as actual_amount
from gl_transactions
where posted_at >= '2026-01-01'
group by 1, 2;
The two rules that prevent most reconciliation headaches:
With both sides at the same grain, the variance report is one query. Use a left join from the budget so that a planned line with zero spend still shows up (a big favorable or unfavorable variance you would otherwise miss), and coalesce the missing actuals to zero.
select
b.period,
b.account,
b.budget_amount,
coalesce(a.actual_amount, 0) as actual_amount,
coalesce(a.actual_amount, 0) - b.budget_amount as variance,
round(
(coalesce(a.actual_amount, 0) - b.budget_amount)
/ nullif(b.budget_amount, 0) * 100
, 1) as variance_pct
from budget b
left join actuals a
on a.period = b.period
and a.account = b.account
order by b.period, b.account;
Two details that matter more than they look:
nullif(b.budget_amount, 0) guards against dividing by zero when something was not budgeted. Spend against a zero budget is real and worth surfacing, so handle it as its own case rather than letting the percentage blow up.left join from budget will miss accounts that had spend but were never budgeted. If unplanned spend is a risk you care about, use a full outer join and coalesce both sides.A raw variance column is not a report. Two lines of interpretation turn it into one.
Direction. Whether a variance is good or bad depends on the account. Coming in under budget is favorable for an expense and unfavorable for revenue. Encode the account type so the report labels itself:
case
when account_type = 'revenue' and variance >= 0 then 'favorable'
when account_type = 'expense' and variance <= 0 then 'favorable'
else 'unfavorable'
end as direction
Materiality. In any real chart of accounts, most lines are within a rounding error of plan every month. If you highlight every non-zero variance, the report becomes noise and people stop reading it. Filter to what is material.
The mistake here is using a single threshold. A percentage-only rule flags a $40 office-snacks line that came in 300% over. A dollar-only rule flags a large line that moved 1%, which is nothing. Neither is worth a meeting.
Use a two-gate rule: a line is an exception only if it clears both a percentage threshold and an absolute dollar threshold.
where abs(variance_pct) >= 10
and abs(variance) >= 5000
This is the piece most homegrown variance reports get wrong, and it is the single change that makes the output trustworthy. Set the two thresholds to your scale (a seed-stage startup might use 10% and $2,000; a larger company 5% and $50,000), then let the report show you the five or ten lines that actually deserve a conversation.
A variance report is mostly a table, and that is fine. But a few chart choices make the gaps read faster.
The reason to build this as a query instead of a spreadsheet is that you run the exact same comparison every close. Automating it removes both the busywork and the transcription errors.
Materiality flagging tells you which variances are large. It does not tell you which ones need action. Pair the two-gate filter with a simple escalation rubric so the monthly review moves quickly.
| Variance size (clears both gates?) | Direction | Recurring or one-off? | Action |
|---|---|---|---|
| No | Either | Either | Note it, no discussion |
| Yes | Favorable | One-off | Confirm the cause, no action |
| Yes | Favorable | Recurring | Consider raising the forecast |
| Yes | Unfavorable | One-off | Explain it in the review, monitor |
| Yes | Unfavorable | Recurring | Owner presents a plan; re-forecast the line |
The “recurring or one-off” column is what most reviews skip. A one-time unfavorable variance (a delayed vendor invoice that hit this month) needs an explanation. A recurring one (cloud spend that has been over plan for three months) needs a decision. Same size, different response.
left join from budget hides accounts with actuals but no plan. If unplanned spend matters, use a full outer join.The right tool depends on how your plan and actuals are stored and who needs to touch the report.
| Tool | Best for | How the plan gets in | Tradeoff |
|---|---|---|---|
| Spreadsheet (Excel, Google Sheets) | Very small teams, one-off analysis | Typed in the sheet | Manual actuals, easy to break, no history |
| BI tool on your warehouse (Basedash, Metabase, Looker) | Teams whose actuals already live in a database | Loaded table or connected sheet | Requires actuals in a queryable source |
| Power BI, Tableau | Established finance teams with an analyst | Modeled in the tool or from a sheet | Heavier setup, less friendly for non-analysts |
| Dedicated FP&A tools (Cube, Pigment, Mosaic) | Complex, multi-scenario planning | Native planning workflows | More tool than a pure variance report needs |
If your actuals already sync to a database or warehouse, a modern BI tool is usually the fastest path to an automated variance report. Basedash, for example, lets you write the join-and-variance query once, connect a budget table or sheet, and share a live report that non-technical teammates can filter and ask follow-up questions of without waiting on the analyst who built it. For a broader look at the category, see the best BI tools for finance teams and the best financial reporting tools.
A variance report in a BI tool works well when the plan is relatively stable and you mainly need to compare it to actuals. It stops being the right tool when planning itself gets complex: multiple simultaneous scenarios, driver-based models where changing one assumption ripples across the plan, rolling re-forecasts every week, or headcount planning tied to the budget. At that point a dedicated FP&A platform earns its cost, and the BI tool becomes the place you report the results, not the place you build the plan.
They are closely related. A budget vs actual report is the artifact: the table comparing planned and actual figures with the variance calculated. Variance analysis is the practice of interpreting those variances, figuring out why each material gap happened, and deciding what to do. The report is the input; the analysis is the work you do with it.
Variance percentage is (actual minus budget) divided by budget, times 100. A positive result means actual came in above budget; negative means below. Guard against a zero budget by handling unbudgeted lines separately, since dividing by zero is undefined and an infinite percentage is not useful.
A favorable variance improves your result relative to plan; an unfavorable one hurts it. The direction flips by account type. For revenue, coming in above budget is favorable. For an expense, coming in below budget is favorable. This is why a variance report should label direction based on account type rather than the sign of the number.
Most teams run it monthly, timed to the accounting close, with a quarterly rollup for board and leadership review. If your actuals refresh from a live data source, keeping the report always-on is cheap, so the monthly review becomes reading an existing report rather than rebuilding one.
Yes. If your actuals live in a database (including a production database or your accounting tool’s export) and your plan lives in a sheet, a BI tool that can read both is enough. A warehouse helps when you have many data sources to combine, but it is not a requirement for a straightforward budget vs actual report.
Written by

Founder and CEO of Basedash
Max Musing is the founder and CEO of Basedash, an AI-native business intelligence platform designed to help teams explore analytics and build dashboards without writing SQL. His work focuses on applying large language models to structured data systems, improving query reliability, and building governed analytics workflows for production environments.
Basedash lets you build charts, dashboards, and reports in seconds using all your data.