How-to
Automating Reporting WorkflowsDeep dive

Build a Lease Liability Schedule

Calculate a lease liability and right-of-use asset the way IFRS 16 does, with pandas: discount the payments at the incremental borrowing rate, unwind interest month by month with payments in advance, depreciate the asset straight line, split the liability into current and non-current portions, produce the month's journals and a maturity analysis, and check the opening liability against Excel's PV function.

Since IFRS 16, a company that leases an office, a warehouse or a fleet of vans records most of those leases on its balance sheet: a lease liability for the payments it has committed to, and a right-of-use asset for the thing it is allowed to use. Both have to be calculated at the start of the lease and then rolled forward every month — the liability grows with interest and falls with each payment, and the asset is depreciated over the lease term. Finance teams usually keep a spreadsheet per lease for this, and each one is a small amortisation table with room for formula mistakes. This page builds the schedule for a portfolio of leases with pandas: the opening liability as the present value of the payments, a month-by-month unwind with payments in advance, the asset's depreciation, the current and non-current split the balance sheet needs, the month's journals and the maturity analysis for the notes — and checks the starting figure against Excel's own PV function. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas numpy openpyxl

The input is a lease register with one row per lease: commencement date, term in months, the fixed monthly payment, paid at the start of each month, and the discount rate — the incremental borrowing rate the company would pay to borrow over a similar term. The month being closed is September 2026. The calculation ignores variable payments, options and remeasurements, which a real register would add as separate events.

Step 1 — The opening liability is the present value of the payments

Python
import numpy as np
import pandas as pd

leases = pd.read_excel("leases.xlsx", parse_dates=["Commencement"])

def opening_liability(payment, annual_rate, months):
    r = annual_rate / 12
    t = np.arange(months)                       # payments in advance: the first is at time 0
    return float((payment / (1 + r) ** t).sum())

leases["Opening liability"] = [round(opening_liability(p, rate, n), 2)
                               for p, rate, n in zip(leases["Monthly payment"], leases["Discount rate"], leases["Term (months)"])]
leases["Total payments"] = leases["Monthly payment"] * leases["Term (months)"]
print(leases[["Lease", "Total payments", "Opening liability"]].to_string(index=False))

The liability at commencement is what the future payments are worth today, discounted at the lease's rate. With payments at the start of each month, the first payment is not discounted at all, the second is discounted by one month, and so on. The monthly rate is the annual rate divided by twelve, which is the common simplification; an effective monthly rate, (1 + r) ** (1/12) − 1, is the alternative some policies prefer, and the code only changes in one line. The head office lease, sixty payments of 12,500 — 750,000 in total — has an opening liability of 657,409.82; the difference of 92,590.18 is the interest that will be charged over five years. The warehouse lease, ten years at 21,000 a month, has a liability of 1,901,000.23 against payments of 2.52 million, and the vans 136,625.55 against 151,200.

Step 2 — Check the opening figure against Excel

Python
from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.append(["Lease", "Python", "Excel PV"])
for i, row in enumerate(leases.itertuples(index=False), start=2):
    ws.append([row.Lease, row._6, f"=PV({row._5}/12,{row._3},-{row._4},0,1)"])
wb.save("pv_check.xlsx")
Bash
soffice --headless --convert-to 'csv:Text - txt - csv (StarCalc):44,34,76,1,,0,false,true,true' pv_check.xlsx > /dev/null 2>&1
cat pv_check.csv

Excel's PV(rate, nper, pmt, fv, type) computes the same present value, and type = 1 tells it that payments are at the start of each period. Writing the formula next to the Python result and letting LibreOffice calculate it gives an independent check: the two columns agree to the penny for all three leases. It is worth keeping such a check for any number that will be audited — the reviewer can see that the Python matches the function they would use themselves.

One month of a lease Each month starts from the opening liability. The payment at the start of the month reduces it, interest at the monthly rate is charged on what remains, and the result is the closing liability. The right-of-use asset is depreciated by a fixed amount each month. Opening liability start of month − Payment in advance + Interest rate ÷ 12 on the rest = Closing liability carried forward Asset − depreciation straight line

Step 3 — Roll the liability and the asset forward

Python
def schedule(lease):
    r, n, pay = lease["Discount rate"] / 12, lease["Term (months)"], lease["Monthly payment"]
    months = pd.period_range(lease["Commencement"], periods=n, freq="M")
    rows, balance = [], lease["Opening liability"]
    dep = lease["Opening liability"] / n                      # right-of-use asset = opening liability here
    for i, m in enumerate(months):
        opening = balance
        after_payment = opening - pay
        interest = round(after_payment * r, 2)
        balance = round(after_payment + interest, 2)
        rows.append({"Lease": lease["Lease"], "Month": m, "Opening": round(opening, 2), "Payment": pay,
                     "Interest": interest, "Closing": balance, "Depreciation": round(dep, 2),
                     "ROU closing": round(lease["Opening liability"] - dep * (i + 1), 2)})
    df = pd.DataFrame(rows)
    df.loc[df.index[-1], "Interest"] += round(-df["Closing"].iloc[-1], 2)    # absorb rounding so it ends at zero
    df.loc[df.index[-1], "Closing"] = 0.0
    return df

sched = pd.concat([schedule(l) for _, l in leases.iterrows()], ignore_index=True)
print(sched.groupby("Lease").agg(interest=("Interest", "sum"), payments=("Payment", "sum"), last=("Closing", "last")).round(2))
print(sched[(sched["Lease"] == "L-01") & (sched["Month"].astype(str).isin(["2025-01", "2025-02", "2029-12"]))]
      [["Month", "Opening", "Payment", "Interest", "Closing"]].to_string(index=False))

Each month the payment is made first, because it is due in advance, and interest is then charged on what remains at the monthly rate. The closing balance becomes next month's opening. Over the term, total interest equals total payments minus the opening liability, and the liability falls to zero with the last payment; rounding to pence each month leaves a few pence over at the end, which are absorbed into the final month's interest so the balance closes exactly at zero. The right-of-use asset starts at the opening liability — it would also include initial direct costs and payments made before commencement, if there were any — and is depreciated in equal monthly amounts over the term.

The head office lease, first two and last months The head office lease starts with a liability of 657,409.82. The January payment of 12,500 is made first and interest of 2,955.84 is charged on the remainder. By the last month the opening balance is just the final payment, no interest arises and the liability closes at zero. A B C D E 1 Month Opening Payment Interest Closing 2 2025-01 657,409.82 12,500 2,955.84 647,865.66 3 2025-02 647,865.66 12,500 2,912.09 638,277.75 4 … … … … … 5 2029-12 12,500 12,500 0 0

Step 4 — The month's figures, the balance sheet split and the maturity analysis

Python
month = pd.Period("2026-09", freq="M")
now = sched[sched["Month"] == month].set_index("Lease")
in_12 = sched[sched["Month"] == month + 12].set_index("Lease")["Closing"].reindex(now.index, fill_value=0.0)
position = pd.DataFrame({"Interest": now["Interest"], "Depreciation": now["Depreciation"], "Payment": now["Payment"],
                         "Liability": now["Closing"], "Current": (now["Closing"] - in_12).round(2),
                         "ROU asset": now["ROU closing"]})
position["Non-current"] = (position["Liability"] - position["Current"]).round(2)
print(position.to_string())
print(position[["Interest", "Depreciation", "Payment", "Liability", "Current", "Non-current"]].sum().round(2).to_dict())

future = sched[sched["Month"] > month]
years_out = (future["Month"] - month).apply(lambda d: d.n) / 12
buckets = pd.cut(years_out, [0, 1, 5, 100], labels=["Within 1 year", "1 to 5 years", "Over 5 years"])
maturity = future.groupby(buckets, observed=False)["Payment"].sum()
print(maturity.to_dict(), "| total undiscounted", maturity.sum())

For the month being closed, each lease contributes interest expense, depreciation and a payment, and ends with a liability and an asset balance. The balance sheet needs the liability split into the part due within twelve months — current — and the rest. The cleanest measure is the reduction in the liability over the next twelve months: the closing liability now minus the closing liability twelve months from now, which is the principal that will be paid off in that time. The maturity analysis in the notes to the accounts uses a different basis — the undiscounted payments still to be made — grouped into within one year, one to five years and over five years. Its total is larger than the liability, because it includes the interest not yet charged. For September 2026 the three leases carry a liability of 2,280,581.66, of which 327,653.55 is current, while the undiscounted payments still due add up to 2,814,300 — 452,400 within a year, 1,416,900 in one to five years and 945,000 after that.

Two ways to describe what is owed The balance sheet shows the discounted liability split into current and non-current parts by the principal repaid in the next twelve months. The maturity analysis shows the undiscounted payments still due, grouped by when they fall due, and its total includes future interest. Balance sheet discounted liability current: principal in 12 months non-current: the rest no future interest Maturity analysis undiscounted payments within 1 year, 1–5, over 5 includes future interest goes in the notes

Step 5 — Journals and the workbook

Python
totals = position.sum()
journals = pd.DataFrame([
    ("Interest expense — leases", totals["Interest"], 0.0), ("Lease liability", 0.0, totals["Interest"]),
    ("Lease liability", totals["Payment"], 0.0), ("Cash", 0.0, totals["Payment"]),
    ("Depreciation — right-of-use assets", totals["Depreciation"], 0.0), ("Right-of-use assets — accumulated depreciation", 0.0, totals["Depreciation"]),
], columns=["Account", "Debit", "Credit"]).round(2)
assert round(journals["Debit"].sum() - journals["Credit"].sum(), 2) == 0

with pd.ExcelWriter("leases_2026-09.xlsx") as xw:
    position.reset_index().to_excel(xw, sheet_name="Position", index=False)
    journals.to_excel(xw, sheet_name="Journals", index=False)
    maturity.rename("Undiscounted payments").reset_index().rename(columns={"index": "When"}).to_excel(xw, sheet_name="Maturity", index=False)
    sched.assign(Month=sched["Month"].astype(str)).to_excel(xw, sheet_name="Schedules", index=False)
print(journals.to_string(index=False))

Three journals record the month: interest increases the liability, the payment reduces it and leaves the bank, and depreciation reduces the asset. The debits and credits are asserted to balance before anything is written. The workbook holds the position by lease, the journals, the maturity table and the full schedules, which double as the audit trail — every month of every lease, from commencement to the final payment. Changes to a lease — a new payment amount after a rent review, an extension — are handled by recalculating the liability from that date with the new payments and the rate at that date, and recording the difference against the asset; the same function serves for that, called with the remaining payments.

Common pitfalls

  • Payments in arrears by accident. In advance means the first payment is not discounted; set type = 1 in Excel.
  • Rounding leftovers. Absorb the final pennies so the liability closes at exactly zero.
  • Current portion as next year's payments. Use the principal reduction, not undiscounted payments.
  • Maturity analysis discounted. It shows undiscounted payments, including future interest.
  • No independent check. Compare the opening liability with PV in a spreadsheet.

Conclusion

A lease liability schedule starts with the present value of the payments at the lease's monthly rate, checked against Excel's PV, then each month deducts the payment in advance, adds interest on the remainder and depreciates the right-of-use asset straight line. From the schedules come the month's interest, depreciation and payment journals, the current and non-current split from the next twelve months' principal reduction, and the undiscounted maturity analysis for the notes.

Frequently asked questions

How do I calculate a lease liability in Python? Discount each payment at the monthly rate — for payments in advance the first is undiscounted — and sum them; that is the liability at commencement.

How do I split a lease liability into current and non-current? Take the closing liability now minus the closing liability twelve months later as current, and the remainder as non-current.

What Excel function checks the opening lease liability?PV(rate/12, months, -payment, 0, 1), where the final 1 means payments at the start of each period.

Why is the maturity analysis bigger than the liability? It shows undiscounted future payments, which include interest that has not been charged yet.

More on report layouts & templates

More on report layouts & templates →