Revenue Recognition and Deferred Revenue
Revenue Recognition and Deferred Revenue
Revenue recognition is one of the most consequential accounting decisions a business makes — it determines when income appears on the P&L, and therefore when investors, lenders, and tax authorities see a healthy or struggling business. Get it wrong and you either inflate current profits (reporting revenue before you've earned it) or deflate them (recording cash that's already been received as a liability). Both distort the picture.
This lesson covers the three main recognition methods and how to query each one, plus backlog calculation, MRR tracking, and a revenue forecast model — the queries that feed finance dashboards, board reports, and fundraising due diligence.
Cash vs Accrual vs Deferred
Before writing SQL, it's worth being precise about what each method means, because they can produce dramatically different numbers for the same underlying business activity:
| Method | When revenue is recognised | Best suited for |
|---|---|---|
| Cash basis | When payment is received in the bank | Small service businesses, freelancers |
| Accrual basis | When the service is delivered / invoice issued | Most companies above a certain size; required by GAAP/IFRS |
| Deferred revenue | Spread across the service period proportionally | Subscriptions, annual retainers, multi-phase projects |
The key tension: a client who pays a £120,000 annual retainer in January has given you the cash, but you've only earned £10,000/month. Cash basis shows £120k in January; deferred (accrual) shows £10k/month for 12 months. The difference is massive on a monthly P&L.
Example 1: Accrual vs Cash Revenue Comparison
This query calculates both accrual revenue (based on invoice issue date — when the service was billed) and cash revenue (based on payment date — when money was actually received) for every calendar month, then places them side by side.
The reason to compare them together is to identify the timing gap: months where you've billed heavily but collected little (growing AR risk), and months where you collected cash from prior-period invoices (which inflates cash revenue that month). A widening gap is a warning sign.
The FULL OUTER JOIN is essential here — there may be months where invoices were issued but nothing paid yet (accrual > 0, cash = 0), or months where old invoices get paid with no new invoicing (cash > 0, accrual = 0). A regular LEFT JOIN or INNER JOIN would lose those months entirely.
WITH monthly_accrual AS (
SELECT DATE_TRUNC('month', i.issue_date)::DATE AS month,
ROUND(SUM(ii.quantity * ii.unit_price), 2) AS accrual_revenue
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
GROUP BY 1
),
monthly_cash AS (
SELECT DATE_TRUNC('month', payment_date)::DATE AS month,
ROUND(SUM(amount), 2) AS cash_revenue
FROM payments GROUP BY 1
)
SELECT COALESCE(a.month, c.month) AS month,
COALESCE(a.accrual_revenue, 0) AS accrual_basis,
COALESCE(c.cash_revenue, 0) AS cash_basis,
COALESCE(a.accrual_revenue, 0) - COALESCE(c.cash_revenue, 0) AS accrual_vs_cash_diff
FROM monthly_accrual a
FULL OUTER JOIN monthly_cash c ON c.month = a.month
ORDER BY 1;
What This Returns
| Column | Explanation |
|---|---|
month |
Calendar month — the COALESCE handles months that appear in one CTE but not the other |
accrual_basis |
Revenue earned this month (invoice issue date) regardless of whether it's been paid |
cash_basis |
Cash actually received this month, which may relate to invoices from previous months |
accrual_vs_cash_diff |
Positive = more was billed than collected (AR building up); Negative = collecting on old invoices |
A healthy business shows a small, stable difference. A growing positive gap means clients are slow to pay. A large negative gap in one month often indicates a year-end or quarter-end collection push.
Example 2: Revenue Backlog
The revenue backlog is the total value of invoices that have been issued (the service or product is contracted) but the due date has not yet arrived — meaning the revenue is committed but not yet recognised. This is a forward-looking health metric: it shows how much revenue is already "locked in" for future periods without any new sales needed.
This query groups future-due invoices by month, then adds a cumulative running total. The running total answers the question: "If no new invoices are created, how much revenue will we see over the next N months?"
-- Invoices issued but not yet due (future revenue)
WITH future_revenue AS (
SELECT
DATE_TRUNC('month', i.due_date)::DATE AS due_month,
COUNT(DISTINCT i.invoice_id) AS invoices,
ROUND(SUM(ii.quantity * ii.unit_price), 2) AS backlog
FROM invoices i
JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
WHERE i.due_date > CURRENT_DATE
GROUP BY 1
)
SELECT due_month, invoices, backlog,
SUM(backlog) OVER (ORDER BY due_month) AS cumulative_backlog
FROM future_revenue ORDER BY due_month;
What This Returns
| Column | Explanation |
|---|---|
due_month |
The month when payment is due for these invoices |
invoices |
Number of distinct invoices due that month |
backlog |
Total invoice value due that month |
cumulative_backlog |
Running total from the earliest future month to this one — "if we collect everything due up to month X, here's the total" |
The SUM(...) OVER (ORDER BY due_month) is a running-sum window function. It doesn't group rows — it keeps each month as its own row while adding a cumulative column. This is much more useful than just summing the whole table, because it lets you see the shape of the revenue pipeline month by month.
Example 3: Monthly Recurring Revenue Tracking
For businesses with a subscription or retainer model, MRR (Monthly Recurring Revenue) is the single most important metric. Unlike one-time revenue, MRR is predictable — it's the annuity component of the business. Investors and acquirers often value subscription businesses as a multiple of MRR rather than annual revenue.
This query builds an MRR table that shows not just the total, but also how many clients are new this month (expansion) versus how many were active last month but not this month (churn). The difference between new and churned gives net growth — the clearest signal of whether the subscriber base is growing or contracting.
The subquery pattern NOT EXISTS (SELECT 1 FROM ... WHERE mc2.month < monthly_clients.month) is the standard way to identify first-time appearances: a client is "new" in month X if there is no record of them in any earlier month.
WITH monthly_clients AS (
SELECT client_id,
DATE_TRUNC('month', issue_date)::DATE AS month,
SUM(ii.quantity * ii.unit_price) AS monthly_rev
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
GROUP BY client_id, 2
HAVING COUNT(DISTINCT i.invoice_id) >= 1
),
mrr AS (
SELECT month,
COUNT(DISTINCT client_id) AS active_clients,
SUM(monthly_rev) AS mrr,
-- New clients this month
COUNT(DISTINCT client_id) FILTER (
WHERE NOT EXISTS (
SELECT 1 FROM monthly_clients mc2
WHERE mc2.client_id = monthly_clients.client_id
AND mc2.month < monthly_clients.month
)
) AS new_clients,
-- Churned from last month
(SELECT COUNT(DISTINCT mc_prev.client_id) FROM monthly_clients mc_prev
WHERE mc_prev.month = monthly_clients.month - INTERVAL '1 month'
AND mc_prev.client_id NOT IN (
SELECT client_id FROM monthly_clients mc_curr
WHERE mc_curr.month = monthly_clients.month
)) AS churned_clients
FROM monthly_clients
GROUP BY month
)
SELECT month, active_clients, ROUND(mrr, 2) AS mrr,
new_clients, churned_clients,
ROUND(100.0 * (active_clients - churned_clients + new_clients - active_clients)
/ NULLIF(active_clients, 0), 1) AS net_growth_pct
FROM mrr ORDER BY month;
What This Returns
| Column | Explanation |
|---|---|
active_clients |
Clients who appeared at least once this month |
mrr |
Total revenue billed to all clients this month |
new_clients |
Clients invoiced this month who had no invoices in any prior month |
churned_clients |
Clients active last month who did not appear this month |
net_growth_pct |
(new - churned) / active * 100 — positive means the subscriber base is growing |
A healthy SaaS business targets a churn rate below 2% monthly (roughly 22% annually). If net_growth_pct is consistently positive, the business is expanding even without counting upsells.
Example 4: Days of Backlog Coverage
Days of Coverage answers a simple but powerful question: if no new invoices are created from today onwards, how many days of business operations are "covered" by the existing outstanding AR? This is a liquidity and revenue visibility metric.
The calculation works by dividing total outstanding AR (invoiced but unpaid) by the daily revenue run rate (total revenue over the past year divided by 365). The result is the number of days' worth of revenue sitting in the AR pipeline.
WITH daily_run_rate AS (
SELECT SUM(ii.quantity * ii.unit_price) / 365.0 AS daily_revenue
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
WHERE i.issue_date >= CURRENT_DATE - INTERVAL '1 year'
),
outstanding_ar AS (
SELECT SUM(ii.quantity * ii.unit_price) - COALESCE(SUM(p.amount), 0) AS total_ar
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
LEFT JOIN payments p ON p.invoice_id = i.invoice_id
WHERE ii.quantity * ii.unit_price > COALESCE(p.amount, 0)
)
SELECT
ROUND(oa.total_ar, 2) AS outstanding_ar,
ROUND(drr.daily_revenue, 2) AS daily_revenue_run_rate,
ROUND(oa.total_ar / NULLIF(drr.daily_revenue, 0), 0) AS days_of_coverage
FROM outstanding_ar oa, daily_run_rate drr;
What This Returns
| Column | Explanation |
|---|---|
outstanding_ar |
Total money owed to you right now (invoiced but not yet paid) |
daily_revenue_run_rate |
Average daily revenue based on the trailing 12 months |
days_of_coverage |
How many days of "normal" revenue this AR represents |
Interpretation benchmarks:
- < 30 days: Thin pipeline, highly dependent on new sales
- 30–90 days: Healthy for most B2B service businesses
- 90–180 days: Strong visibility; possibly some very long payment terms
- > 180 days: Check for stale AR — old invoices that may never be collected are inflating this number