Fixing Floating-Point Drift in Lease Amortization Schedules
Why binary float breaks penny-exact ASC 842 / IFRS 16 amortization schedules, and how decimal Decimal with quantize and a terminal adjustment restores a zero-close, re-derivable liability.
Problem Statement Link to this section
A lease amortization schedule built with Python float looks correct in a notebook and then fails the one test that matters: it does not close to exactly zero, and two runs on two machines do not agree to the cent. The reason is not a logic bug in the effective-interest recursion — it is that binary floating point cannot represent most decimal cent amounts exactly. The value 0.10 is stored as a binary fraction slightly larger than a tenth, 0.29 slightly smaller, and every interest accrual, principal split, and balance carry-forward compounds that representation error across dozens of periods. On a monthly lease over a five- or ten-year term the accumulated drift is routinely a few cents — invisible on screen, fatal to an audit that re-derives the schedule and expects the published figures back. This page shows exactly why float breaks a penny-exact schedule and how decimal.Decimal with per-period quantize and a terminal adjustment produces a liability that unwinds to zero and reproduces byte-for-byte. It is one failure mode in the broader amortization precision troubleshooting cluster.
Standard Anchor Link to this section
The requirement that forces exactness is the subsequent-measurement provision read together with the audit expectation of re-derivability. ASC 842-20-35-3 directs the lessee to increase the lease liability for interest and decrease it for payments using the interest method, and IFRS 16.36 requires the same, with interest producing a constant periodic rate on the remaining balance. Neither standard tabulates the numbers; both mandate a method. That is the key point for engineers: because the reported interest expense and closing liability are outputs of a method, they are only supported if the method re-runs deterministically and reproduces them. An auditor re-performing the calculation takes the same payments and the same locked rate and expects the same schedule, down to a terminal balance of exactly zero. A float schedule cannot guarantee that byte-for-byte reproduction, so even when its figures are "close enough" to look right, they are unsupported in the sense the standard and SOX Section 404 require. Exact decimal arithmetic is therefore not an optimization — it is what makes the figures defensible.
Algorithm Specification: Where the Error Accumulates Link to this section
Represent the terminal error as the sum of per-period representation errors. Under exact decimal arithmetic with cent quantization, each period contributes at most half a cent, so the worst-case residual after
Under binary float, there is no comparable clean bound. Each stored cent already carries a representation error on the order of machine epsilon relative to its magnitude,
The fix has two parts. First, do the arithmetic in Decimal, which represents 0.01 exactly, so
Annotated Python Snippet: Float Wrong, Decimal Correct Link to this section
The two functions below run the identical effective-interest recursion on the identical inputs. The first accumulates in float and leaves a residual; the second uses Decimal, quantizes each period to the cent, applies a terminal adjustment, and asserts a zero close.
from decimal import Decimal, ROUND_HALF_UP
# WRONG — float money drifts; cents have no exact binary representation
def schedule_float(ll0, rate, payment, n):
balance, rows = ll0, []
for t in range(1, n + 1):
interest = round(balance * rate, 2) # float * float, then round
principal = round(payment - interest, 2)
balance = round(balance - principal, 2) # error compounds each period
rows.append((t, interest, principal, balance))
return rows # rows[-1] balance != 0.0
# CORRECT — Decimal money is exact; residual absorbed in the final period
def schedule_decimal(ll0: Decimal, rate: Decimal, payment: Decimal, n: int):
cent = Decimal("0.01")
balance, rows = ll0, []
for t in range(1, n + 1):
interest = (balance * rate).quantize(cent, ROUND_HALF_UP)
if t == n: # terminal adjustment
principal = balance # close the balance exactly
interest = (payment - principal).quantize(cent, ROUND_HALF_UP)
else:
principal = (payment - interest).quantize(cent, ROUND_HALF_UP)
balance = (balance - principal).quantize(cent, ROUND_HALF_UP)
rows.append((t, interest, principal, balance))
return rows
bad = schedule_float(100000.00, 0.005, 1933.28, 60)
good = schedule_decimal(Decimal("100000.00"), Decimal("0.005"),
Decimal("1933.28"), 60)
# The float schedule leaves a residual; the Decimal schedule closes to zero.
assert bad[-1][3] != 0.0 # float drifts off zero
assert good[-1][3] == Decimal("0.00"), "Decimal schedule must close to zero"
The final assertion is the guardrail an auditor's re-derivation would apply. Keep it inside the function that publishes the schedule so it runs on every lease in CI, not just in an ad-hoc check. Note that Decimal("0.005") is built from a string; writing Decimal(0.005) from a float literal would import the very drift the rewrite is meant to remove.
Correct vs. Incorrect Link to this section
| Aspect | float schedule | Decimal schedule |
|---|---|---|
| Cent representation | 0.01 stored as an inexact binary fraction |
0.01 represented exactly |
| Per-period error | Nonzero before any rounding, then compounds | Bounded to half a cent by deliberate quantize |
| Terminal balance | Drifts a few cents off zero over a long term | Forced to exactly zero by the terminal adjustment |
| Reproducibility | May differ across machines and library versions | Byte-for-byte identical from the same inputs |
| Audit re-derivation | Fails; figures unsupported | Passes; figures re-derive |
| Correct use | None for money | All money, balances, rates, and factors |
Gotcha: Decimal Built From a Float Literal Link to this section
The most common way a rewrite still drifts is constructing a Decimal from a float instead of a str. Decimal(0.06) captures the binary approximation of 0.06 and carries roughly seventeen junk digits into the context; Decimal("0.06") is exactly six hundredths.
from decimal import Decimal
Decimal(0.06) # Decimal('0.059999999999999997779553950749686919152736663818359375')
Decimal("0.06") # Decimal('0.06') <- always build money from a string
The failure hides because a single contaminated rate looks fine for a few periods and only fails the terminal assert after drift accumulates. Guard against it at the engine boundary: reject float inputs so contamination raises a TypeError at ingestion rather than surfacing as a broken schedule weeks later. Upstream, a json.load yields float and a pandas column defaults to float64, so cast to Decimal(str(value)) at the seam where external data enters the engine.
Frequently Asked Questions Link to this section
Isn't rounding each period to the cent enough to keep a float schedule exact?
No. Rounding with round(x, 2) in float still stores the result as a binary fraction that is only approximately the rounded cent, so the next multiplication and subtraction reintroduce error. Rounding controls how large each step's error is but cannot make 0.01 representable in binary, so a long schedule still drifts. Only exact decimal arithmetic removes the representation error at its source; rounding then bounds the deliberate cent loss on top of an already-exact value.
Why not just compare the terminal balance against a small tolerance instead of zero?
Because a tolerance hides the very defect it is meant to tolerate. An auditor re-deriving the schedule expects the published figures exactly, not within a band, and a tolerance would also mask a genuine logic error — an interest accrued on the wrong balance, say — as long as the drift stayed under the threshold. The terminal adjustment is cheap and deterministic, so there is no reason to accept a nonzero close; assert equality with Decimal("0.00") and keep the check as a hard gate.
Does the terminal adjustment distort the final period's interest expense?
Only by the accumulated rounding residual, which is bounded by half a cent times the number of periods and is immaterial by construction. The adjustment reassigns at most a few cents from interest to principal (or the reverse) in the final row so the balance lands on zero; the periodic rate and every prior period are untouched. This is the standard, expected treatment — absorbing residual rounding into the last period is how the effective-interest method is made to close exactly.
Related Link to this section
- Debugging day-count mismatches in lease interest — the sibling failure mode where the opening liability and first interest figure disagree because two day-count bases were used.
- Amortization precision troubleshooting — the parent guide covering all five failure modes that break a lease amortization engine.
- Liability amortization and schedule generation — the framework this schedule serves, from opening present value to zero terminal balance.