How-to
Automating Reporting WorkflowsDeep dive

Build a Deferred Revenue Schedule

Spread prepaid subscription and contract revenue over the service period with pandas: daily pro-rata recognition for contracts that start mid-month, a contract-by-month schedule, the deferred revenue balance at each month end, the monthly journal, a check that invoiced equals recognised plus deferred, and the schedule written to Excel.

A customer pays 12,000 in March for a year's subscription starting on 15 March. The cash is in the bank, but the revenue is not earned yet: it is earned day by day as the service is delivered, and until then the unearned part sits on the balance sheet as deferred revenue. Every month, finance must work out how much of each prepaid contract to recognise and what remains deferred. With a handful of contracts that is a spreadsheet; with hundreds, each starting on a different day and some with odd lengths, the spreadsheet becomes fragile — one dragged formula off by a row and the month's revenue is wrong. pandas builds the whole schedule from the contract list in a few lines, with daily precision and a built-in reconciliation. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl

contracts.xlsx lists five prepaid contracts with their invoice date, amount and service period — a one-year subscription starting mid-January, a six-month contract, a one-year contract from 15 March, a quarterly contract and a two-year contract from 10 September. The schedule covers January 2026 to December 2027, and month-end reporting is for September 2026.

Step 1 — Daily revenue for each contract

The fairest and most common straight-line method spreads the amount evenly over every day of the service period, so a contract starting on the 15th earns about half a month in its first month:

Python
import pandas as pd

contracts = pd.read_excel("contracts.xlsx")
contracts["Days"] = (contracts["Service end"] - contracts["Service start"]).dt.days + 1
contracts["Per day"] = contracts["Amount"] / contracts["Days"]
print(contracts[["Contract", "Service start", "Service end", "Days", "Amount", "Per day"]].round({"Per day": 4}).to_string(index=False))

Other recognition patterns exist — usage-based, milestone-based, or equal monthly amounts regardless of days — and the contract terms decide which applies; straight-line daily is the usual default for subscriptions and support contracts, and the structure below works for the others by changing how each month's share is computed. The + 1 counts both the first and last day: 15 January 2026 to 14 January 2027 is 365 days. Contract C-1003 covers 365 days for 9,125, which is exactly 25 a day; the two-year C-1005 runs over 2027–2028 and includes 29 February 2028, so it has 731 days. Daily rates are kept unrounded here; rounding happens once, per month, in the next step, so the parts add back to the invoice.

Step 2 — Spread the days into months

For each contract and month, count the service days that fall in the month and multiply by the daily rate. Building the overlap with dates rather than loops keeps it fast for thousands of contracts:

Python
import numpy as np

months = pd.period_range("2026-01", "2028-09", freq="M")
m_start = months.to_timestamp(how="start")
m_end = months.to_timestamp(how="end").normalize()

def schedule_row(c):
    start = m_start.where(m_start > c["Service start"], c["Service start"])
    end = m_end.where(m_end < c["Service end"], c["Service end"])
    days = np.maximum((end - start).days + 1, 0)
    return pd.Series(np.round(days * c["Per day"], 2), index=months.astype(str))

sched = contracts.set_index("Contract").apply(schedule_row, axis=1)
# put any rounding difference in the final month so each contract totals its invoice exactly
diff = (contracts.set_index("Contract")["Amount"] - sched.sum(axis=1)).round(2)
last_month = sched.apply(lambda r: r[r > 0].index[-1], axis=1)
for c, m in last_month.items():
    sched.loc[c, m] = round(sched.loc[c, m] + diff[c], 2)
print(sched.loc[:, "2026-01":"2026-04"].to_string())
print("contract totals equal invoices:", (sched.sum(axis=1).round(2) == contracts.set_index("Contract")["Amount"]).all())

For each month, the overlap starts at the later of the month start and the service start and ends at the earlier of the month end and the service end; a negative day count means no overlap and is clipped to zero. C-1001 earns 17 days in January (15–31 January) and a full month after that. np.maximum(..., 0) does the clipping element-wise across all months at once. Laying the months out as columns in this way also makes the schedule easy to read in Excel, where accountants expect one row per contract and one column per period. Rounding each month to cents leaves a few cents of difference over a contract's life; the code puts that difference in the contract's last month so every contract recognises exactly its invoiced amount — auditors check this.

How contract C-1001 is recognised The 12,000 one-year contract starts on 15 January 2026 at about 32.88 a day. January recognises 17 days, about 559. Each following full month recognises its days, about 920 to 1,019. The contract ends on 14 January 2027 with 14 days recognised. 15 Jan 2026 start · 32.88/day Jan 2026 17 days · 558.90 Feb–Dec 2026 full months Jan 2027 14 days · rest 14 Jan 2027 fully recognised

Step 3 — Deferred balance at each month end

Deferred revenue at a month end is everything invoiced so far minus everything recognised so far. Invoices are recorded in the month they are issued, which may be before the service starts:

Python
invoiced = (contracts.assign(Month=contracts["Invoice date"].dt.to_period("M").astype(str))
                     .pivot_table(index="Contract", columns="Month", values="Amount", aggfunc="sum")
                     .reindex(columns=sched.columns, fill_value=0).fillna(0))

roll = pd.DataFrame({
    "Invoiced": invoiced.sum(),
    "Recognised": sched.sum(),
})
roll["Deferred (closing)"] = (roll["Invoiced"] - roll["Recognised"]).cumsum().round(2)
roll["Deferred (opening)"] = roll["Deferred (closing)"].shift(fill_value=0)
roll = roll[["Deferred (opening)", "Invoiced", "Recognised", "Deferred (closing)"]]
print(roll.loc["2026-06":"2026-10"].round(2).to_string())

This is the deferred revenue roll-forward: opening balance, plus new invoices, minus revenue recognised, equals closing balance. In September 2026 the two-year 24,000 contract is invoiced, so the balance jumps, while recognition continues on the others. The closing balance for September is the figure that must agree with the deferred revenue account in the general ledger — see Build a Trial Balance from a General Ledger.

Step 4 — The month's journal and the checks

Each month, one journal moves the recognised amount from the deferred revenue liability to revenue. Generate it from the schedule, and check that the schedule reconciles before posting:

Python
month = "2026-09"
journal = pd.DataFrame([
    {"Account": "2400 Deferred revenue", "Debit": round(sched[month].sum(), 2), "Credit": 0.0},
    {"Account": "4100 Subscription revenue", "Debit": 0.0, "Credit": round(sched[month].sum(), 2)},
])
print(journal.to_string(index=False))
print("by contract:", sched[month][sched[month] > 0].round(2).to_dict())

total_invoiced = contracts["Amount"].sum()
recognised_to_date = sched.loc[:, :month].sum().sum()
deferred_now = roll.loc[month, "Deferred (closing)"]
print(f"invoiced {total_invoiced:,.2f} = recognised to date {recognised_to_date:,.2f} + deferred {deferred_now:,.2f}:",
      round(recognised_to_date + deferred_now, 2) == round(total_invoiced, 2))

The journal debits deferred revenue and credits revenue for the month's total, with a by-contract breakdown for the audit file. The final check is the identity that keeps the schedule honest: everything invoiced to date equals what has been recognised plus what is still deferred. C-1002 finished at the end of August and recognises nothing; C-1004 recognises its final month, 30 days at 1,800 ÷ 92 a day; and C-1005, which started on 10 September, recognises 21 days.

September 2026 revenue recognised by contract Revenue recognised in September 2026. The one-year contracts C-1001 and C-1003 each recognise a full month, about 986 and 750. The quarterly contract C-1004 recognises its last month, 586.96. The new two-year contract C-1005 recognises 21 days, about 689. C-1001 Oakridge Farms 986.30 · full month C-1003 Zenith Logistics 750.00 · 30 days × 25 C-1004 Haddad & Sons 586.96 · final month C-1005 Lindqvist AB 689.47 · from 10 Sep

Step 5 — Write the schedule to Excel

The workbook carries the roll-forward, the contract-by-month schedule and the journal:

Python
from openpyxl.styles import Font

with pd.ExcelWriter("deferred_revenue_2026-09.xlsx", engine="openpyxl") as xw:
    roll.round(2).to_excel(xw, sheet_name="Roll-forward")
    out = contracts.set_index("Contract")[["Customer", "Amount", "Service start", "Service end"]].join(sched)
    out.to_excel(xw, sheet_name="Schedule")
    journal.to_excel(xw, sheet_name=f"Journal {month}", index=False)
    for ws in xw.book.worksheets:
        for c in ws[1]:
            c.font = Font(bold=True)
        ws.freeze_panes = "B2" if ws.title != "Schedule" else "F2"
    ws = xw.sheets["Schedule"]
    for row in ws.iter_rows(min_row=2, min_col=3):
        for c in row:
            if isinstance(c.value, float):
                c.number_format = "#,##0.00;-#,##0.00;\"\""
print(out.shape[1] - 4, "monthly columns written")

The schedule sheet freezes the contract details on the left so the monthly columns scroll past them, and the number format's third section ("") hides zeros, so each contract's recognition shows as a band across the months it covers — a quick visual check for start and end dates. The roll-forward goes first because it is what the month-end reviewer signs off. Adding the totals as formulas, or a chart of the deferred balance over time, follows the same patterns as Build a Fixed Asset Depreciation Schedule, whose contract-by-month structure is the same.

From contracts to month-end figures Each contract's amount becomes a daily rate over its service days, the days are spread into months and rounded with the remainder put in the last month, invoices and recognition roll the deferred balance forward, and the month's journal and reconciliation come out of the schedule. Daily rate amount ÷ service days Monthly schedule overlap days × rate Roll-forward opening + invoiced − recognised Journal + check Dr deferred / Cr revenue

Common pitfalls

  • Off-by-one service days. Count both the start and end day.
  • Equal monthly amounts for mid-month starts. Use daily pro-rata so partial months are fair.
  • Rounding drift. Round per month and put the remainder in the final month so totals match invoices.
  • Invoices recorded in the service month. Deferred revenue starts when the invoice is issued.
  • No reconciliation. Check invoiced equals recognised plus deferred every month.

Conclusion

A deferred revenue schedule is daily straight-line recognition made systematic: compute each contract's daily rate over its service days, count the days each month overlaps the service period, round per month with any remainder in the final month, and roll the deferred balance forward from invoices and recognition. The month's journal, the by-contract breakdown and the check that invoiced equals recognised plus deferred all come from the same schedule, which then goes to Excel for review.

Frequently asked questions

How do I calculate deferred revenue in Python? For each month, deferred revenue is the cumulative amount invoiced minus the cumulative revenue recognised. Build a contract-by-month recognition schedule and take the running difference.

How do I recognise revenue for a contract that starts mid-month? Divide the contract amount by its number of service days and multiply by the days of service in each month, so the first and last months get a partial share.

How do I stop rounding differences in a revenue schedule? Round each monthly amount to cents and add the remaining difference to the contract's final month, so every contract recognises exactly its invoiced amount.

What journal records monthly revenue recognition? Debit the deferred revenue liability and credit revenue for the total recognised in the month, with a breakdown by contract kept as support.

More on report layouts & templates

More on report layouts & templates →