Budget vs Actual Variance Analysis

Budget vs Actual Variance Analysis

Every management team wants the same thing at month end: a single table showing what was planned versus what actually happened, and exactly where the gap is. This is called variance analysis, and it is the core of every CFO dashboard, board pack, and quarterly business review.

Variance analysis is more nuanced than it sounds. Revenue can miss budget because fewer clients paid (volume variance) or because clients paid less on average (price/mix variance). A good variance report separates these drivers rather than showing a single "you missed by £X" number. This lesson walks through building variance analysis SQL step by step — from simple actuals aggregation through to year-over-year comparisons and account manager league tables.

Why Variance Analysis Matters

  • Finance teams use it to explain the business to the board and investors
  • Sales teams use it to understand which accounts or segments are underperforming
  • Operations use it to identify months where revenue was strong but collection rate fell (billing vs. cash issue)
  • Leadership uses it to decide whether shortfalls require corrective action or are explained by known factors (lost contract, seasonal dip, payment timing)

The same SQL pattern — actual vs. target with a CASE statement labelling the status — powers almost every KPI dashboard you'll ever build.

Dataset Context

-- invoices: id, client_id, issue_date, due_date, total_amount, status
-- payments: id, invoice_id, payment_date, amount
-- clients: id, name, industry, account_manager
-- Assume budget_targets table: month, category, target_revenue

Step 1 — Actual Revenue by Month and Industry

Before you can compare actuals to a budget, you need a clean actuals table. This query calculates total revenue collected (from the payments table, not just invoices) broken down by month and client industry segment. The distinction between "billed" and "collected" matters: an invoice issued in March may only be paid in May. For cash-basis reporting, you want the payment date; for accrual-basis, you want the invoice date.

This step uses only paid invoices (WHERE i.status = 'paid') to ensure we're measuring money that actually moved, not just invoices sent.

WITH actuals AS (
  SELECT
    DATE_TRUNC('month', i.issue_date) AS month,
    c.industry,
    SUM(p.amount) AS actual_revenue,
    COUNT(DISTINCT i.invoice_id) AS invoices_paid,
    COUNT(DISTINCT i.client_id) AS paying_clients
  FROM invoices i
  JOIN payments p ON p.invoice_id = i.invoice_id
  JOIN clients c ON c.client_id = i.client_id
  WHERE i.status = 'paid'
  GROUP BY DATE_TRUNC('month', i.issue_date), c.industry
)
SELECT * FROM actuals ORDER BY month, industry;

What This Returns

Column Explanation
month Calendar month (truncated to first day)
industry Client industry segment — useful for spotting which verticals are driving or missing revenue
actual_revenue Total cash received from paid invoices in this month/industry combination
invoices_paid Number of distinct invoices settled — a drop here vs. prior months signals collection slowdown
paying_clients Unique clients who paid — if revenue holds but client count drops, fewer clients are paying larger amounts (concentration risk)

Step 2 — Month-over-Month Growth Rate

Once you have actuals by month, the next most useful metric is growth rate — how does this month compare to last month? This query uses the LAG() window function to bring the previous month's collected revenue forward, then calculates the percentage change.

It also includes a collection rate column (collected / billed) which is a completely different metric from growth. You can have strong growth in billed revenue while your collection rate is declining — meaning you're invoicing more but actually receiving less of it.

WITH monthly AS (
  SELECT
    DATE_TRUNC('month', issue_date) AS month,
    SUM(total_amount) AS billed,
    SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END) AS collected
  FROM invoices
  GROUP BY DATE_TRUNC('month', issue_date)
),
with_growth AS (
  SELECT
    month,
    billed,
    collected,
    ROUND(collected::numeric / NULLIF(billed, 0) * 100, 1) AS collection_rate_pct,
    LAG(collected) OVER (ORDER BY month) AS prev_collected,
    ROUND(
      (collected - LAG(collected) OVER (ORDER BY month))::numeric
      / NULLIF(LAG(collected) OVER (ORDER BY month), 0) * 100, 1
    ) AS mom_growth_pct
  FROM monthly
)
SELECT * FROM with_growth ORDER BY month;

What This Returns

Column Explanation
billed Total invoiced this month regardless of payment status
collected Only what was actually paid
collection_rate_pct Percentage of billed revenue collected — target is 90%+ for healthy businesses
prev_collected Prior month's collected amount (pulled forward by LAG)
mom_growth_pct Month-on-month growth in collections — the primary revenue momentum indicator

Purchase this course to unlock the full lesson.

Sign up