Generating a Lease Liability Rollforward in Python

A focused, runnable how-to that builds an ASC 842 / IFRS 16 lease-liability rollforward table from amortization schedule rows and modification events, with a terminal reconciliation assert.

Problem Statement Link to this section

This page answers one narrow, practical question: given a portfolio of amortization schedules and a log of modification events, how do you assemble the period lease-liability rollforward table in Python so that it reconciles to the cent? The rollforward is the movement schedule that carries the opening lease liability to the closing balance through four flows — additions for new leases, interest accreted, cash paid, and the net of remeasurements. The arithmetic is a handful of sums; the difficulty is entirely in getting the signs, the scope of each window, and the rounding path right so that the closing figure ties to both the underlying schedules and the balance sheet. The discount rate on each schedule is the one locked at commencement, whether the rate implicit in the lease or the incremental borrowing rate, so every interest figure the rollforward consumes is already deterministic. This is the runnable companion to lease liability rollforward and reconciliation.

Standard Anchor Link to this section

Two strands of guidance bound this build. ASC 842-20-45-1 through 45-5 govern presentation: the lease liability is presented split between current and non-current, and finance-lease principal repayments are presented within financing activities while operating-lease payments sit in operating. ASC 842-20-50-4 requires disclosure of the cash paid for amounts included in the measurement of lease liabilities — the payment leg of the rollforward. On the IFRS side, IFRS 16.53 requires disclosure of interest expense on lease liabilities, additions to right-of-use assets, and, in 16.53(g), the total cash outflow for leases; IFRS 16 does not mandate a rollforward table, but the same movement figures are exactly what a voluntary rollforward and the IAS 7 financing-liabilities reconciliation both need.

The interest leg is fixed by ASC 842-20-35-3 / IFRS 16.38: the liability accretes at the locked rate on the opening balance each period. Because the rollforward only aggregates figures the schedules already produced, it introduces no new measurement — it is a reconciliation, and its correctness is judged by whether the closing balance ties.

Formula / Algorithm Specification Link to this section

The rollforward is the identity:

where is the opening liability (the prior period's closing balance), is the sum of opening present values of leases that commenced in the window, is total interest accreted, is total cash paid against lease liabilities, and is the net signed remeasurement adjustment. The interest and payment aggregates run over every in-window schedule row for every lease :

The financing cash outflow presented under ASC 842-20-45-5 and disclosed under IFRS 16.53(g) is the finance-lease principal, . The reconciliation invariant is that the recomputed right-hand side equals both the schedules' independent closing total and the reported balance-sheet figure, to Decimal("0.00").

Annotated Python Snippet Link to this section

The snippet builds the rollforward from ScheduleRow and RemeasurementEvent records, aggregates each leg with decimal.Decimal, and ends with a terminal assert proving the identity closes exactly against the schedules' own closing total.

from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP, getcontext
from datetime import date

getcontext().prec = 28
CENT = Decimal("0.01")


def q(x: Decimal) -> Decimal:                       # quantize once, at the cent
    return x.quantize(CENT, rounding=ROUND_HALF_UP)


@dataclass(frozen=True)
class ScheduleRow:
    lease_id: str
    period_end: date
    interest: Decimal                                # ASC 842-20-35-3 / IFRS 16.38
    payment: Decimal                                 # cash leg (ASC 842-20-50-4)
    closing: Decimal
    classification: str                              # "finance" | "operating"
    is_commencement: bool = False                    # first period of a new lease
    opening_pv: Decimal = Decimal("0")               # addition when commencing


@dataclass(frozen=True)
class RemeasurementEvent:
    lease_id: str
    event_date: date
    liability_delta: Decimal                         # +increase / -decrease (16.44-46)


def build_rollforward(opening: Decimal, rows: list[ScheduleRow],
                      events: list[RemeasurementEvent],
                      start: date, end: date) -> dict:
    """Assemble a period lease-liability rollforward (ASC 842-20-45 / IFRS 16.53)."""
    win = [r for r in rows if start <= r.period_end <= end]
    additions = sum((r.opening_pv for r in win if r.is_commencement), Decimal("0"))
    interest = sum((r.interest for r in win), Decimal("0"))
    payments = sum((r.payment for r in win), Decimal("0"))
    financing = sum((r.payment - r.interest for r in win
                     if r.classification == "finance"), Decimal("0"))
    remeasure = sum((e.liability_delta for e in events
                     if start <= e.event_date <= end), Decimal("0"))
    closing = opening + additions + interest - payments + remeasure   # the identity
    return {"opening": q(opening), "additions": q(additions), "interest": q(interest),
            "payments": q(payments), "remeasurements": q(remeasure),
            "closing": q(closing), "financing_outflow": q(financing)}


if __name__ == "__main__":
    rows = [
        ScheduleRow("L1", date(2026, 3, 31), Decimal("512.00"),
                    Decimal("3059.97"), Decimal("97452.03"), "finance"),
        ScheduleRow("L2", date(2026, 3, 31), Decimal("180.00"),
                    Decimal("1500.00"), Decimal("34680.00"), "finance",
                    is_commencement=True, opening_pv=Decimal("36000.00")),
    ]
    events = [RemeasurementEvent("L1", date(2026, 3, 15), Decimal("2500.00"))]
    rf = build_rollforward(Decimal("94000.00"), rows, events,
                           date(2026, 1, 1), date(2026, 3, 31))
    schedule_closing = sum((r.closing for r in rows), Decimal("0"))   # 132132.03

    # Terminal reconciliation gate: identity closes AND ties to the schedules
    identity = (rf["opening"] + rf["additions"] + rf["interest"]
                - rf["payments"] + rf["remeasurements"])
    assert q(identity) == rf["closing"] == q(schedule_closing), "rollforward broke"
    print("closing liability:", rf["closing"], "| financing:", rf["financing_outflow"])

The assertion is the guardrail. It checks two things at once: that the identity recomputes to the reported closing, and that the closing ties to the schedules' independent closing total. If a schedule is missing from rows, an event is misdated, or the payment sign is flipped, one of the equalities breaks and the table cannot be published. Keep the check inside the build path so it runs on every portfolio in CI, not just in an ad-hoc notebook.

Correct vs. Incorrect Leg Signs Link to this section

The single most consequential choice is the sign on each leg. Interest and additions increase the liability; payments decrease it; remeasurements are signed. Wiring a leg the wrong way produces a closing balance that fails to tie, usually by roughly twice the misplaced figure.

Leg Correct treatment Common mistake Effect of the mistake
Interest Added to the liability Netted against payments before summing Understates both interest and payments legs
Payments Subtracted from the liability Added as a positive movement Closing overstated by about twice payments
Additions Added once, at opening PV Also left in the opening balance Closing overstated by the new-lease PV
Remeasurement increase Positive R Recorded and schedule also regenerated Change double-counted
Remeasurement decrease Negative R Entered as a positive delta Closing moves the wrong direction

Neither the interest nor the payment figure is optional or interchangeable — they are separate legs pulled from separate columns of the same schedule, and the financing cash outflow disclosure depends on keeping them apart.

Gotcha / Failure Mode: The Double-Counted New Lease Link to this section

A lease that commences during the period is the classic double-count. Its opening present value belongs in the additions leg, and from its first period it also accretes interest and takes payments inside the same window. If its opening balance has also crept into the opening-liability figure — for example because the opening balance was recomputed by summing all schedules' first-row openings rather than taken strictly from the prior period's closing — the new lease is counted twice and the closing balance overstates by its present value.

Before (WRONG — opening rebuilt from all schedules, new lease leaks in):

opening = sum(sched[0].opening for sched in all_schedules)   # includes new leases!
closing = opening + additions + interest - payments + remeasure

After (correct — opening is strictly the prior period's reported closing):

opening = prior_period_reported_closing        # new leases enter ONLY via additions
closing = opening + additions + interest - payments + remeasure

Debug checklist when the closing balance fails to tie:

  1. Confirm the opening. It must equal the prior period's reported closing balance exactly, never a re-sum of schedule openings.
  2. Check for a double-counted commencement. A new lease belongs in additions only; its opening PV must not appear in the opening figure.
  3. Verify event dates. Each remeasurement must fall inside the window, and the affected schedule must already reflect the revised balance.
  4. Match the rounding path. Source the closing from the schedules' terminal balances and quantize the aggregate once; do not compare two differently rounded closings.

Frequently Asked Questions Link to this section

Where does the interest figure in the rollforward come from?

Straight from the interest column of the amortization schedules — the rollforward never recomputes it. Each schedule accretes interest on its opening balance at the rate locked at commencement (ASC 842-20-35-3 / IFRS 16.38), and the rollforward's interest leg is the sum of those figures across every in-window row. Pulling interest from the same schedules that drive the balance sheet is what guarantees the interest expense line and the closing liability can never silently disagree.

How do I compute the financing cash outflow from the rollforward?

Take the finance-classified payments and subtract their interest portion: the financing outflow is the finance-lease principal, presented within financing activities under ASC 842-20-45-5. Operating-lease payments and the interest portion of finance payments are presented in operating activities. IFRS 16.53(g) separately requires the total cash outflow for all leases. Carrying a classification flag on each schedule row lets you split the payment leg at aggregation time and reproduce both figures.

What if a lease both commences and is remeasured in the same period?

Handle them as two independent legs. The commencement contributes its opening present value to additions; a later remeasurement in the same window contributes its signed delta to the remeasurement leg, with the schedule regenerated from the revised balance as of the effective date. As long as the opening balance excludes the new lease and the modification is not also folded back into additions, the two legs coexist without double-counting. The general mechanics are in lease modification and remeasurement accounting.