Two loan offers land in your inbox. Both say 6.5%. One has a $1,200 origination fee and the other has none. The monthly payment on the first is not a cent higher than on the second, because the fee is taken out of the money before it reaches you. Compare the quoted rates and the two offers look identical. On a $20,000 five-year loan, the fee turns 6.5% into a 9.12% APR.
The APR is the number that makes them comparable, and it is just the internal rate of return on the loan's cash flows. This post derives it, implements it, and shows the two places where a quoted rate misleads: front-loaded fees and flat-rate interest.
What the quoted rate actually is
A loan quote gives you a nominal annual rate r and a term of n months. The payment follows the annuity formula:
payment = P * (r/12) / (1 - (1 + r/12)^(-n))
For $20,000 at 6.5% over 60 months that is $391.32 a month, or $23,479.38 in total, of which $3,479.38 is interest. So far so ordinary. The quoted rate prices the principal on the assumption that you receive all of it.
APR is the rate where the cash flows net to zero
Now add a $1,200 origination fee, deducted at closing. You still owe payments on $20,000, but only $18,800 ever hits your account. Economically you borrowed $18,800 and are paying $391.32 a month for 60 months.
The APR is the annualised discount rate that makes the present value of those payments equal the money you actually received:
18,800 = sum(payment / (1 + i)^k) for k = 1..60
APR = 12 * i
There is no closed-form solution for i, since the equation is a polynomial of degree 60, but there is exactly one positive root: the present value falls monotonically as i rises, and that is all a bisection search needs.
Using 12 * i rather than compounding to (1+i)^12 - 1 is deliberate. US Regulation Z defines APR as a nominal annual rate, so a monthly IRR is multiplied by twelve. Comparing it to an APY from a savings account is apples to oranges: APY compounds and APR does not.
The implementation
def payment(principal: float, annual_rate: float, months: int) -> float:
"""Fixed monthly payment on an amortising loan."""
r = annual_rate / 12
if r == 0:
return principal / months
return principal * r / (1 - (1 + r) ** -months)
def apr(net_advanced: float, monthly_payment: float, months: int) -> float:
"""Annualised IRR of the loan's cash flows, as a decimal (0.0912 = 9.12%)."""
lo, hi = 0.0, 3.0 # 0% .. 300% monthly, plenty of room
for _ in range(200): # 200 halvings is far past float precision
mid = (lo + hi) / 2
pv = sum(monthly_payment / (1 + mid) ** k for k in range(1, months + 1))
if pv > net_advanced: # rate too low: payments are worth too much
lo = mid
else:
hi = mid
return ((lo + hi) / 2) * 12
Two details matter in that function. Bisection rather than Newton, because pv(i) is smooth and strictly decreasing, so the search cannot diverge; a hundred iterations is already overkill and two hundred costs microseconds. And net_advanced is the money you can spend, not the face value. Fees deducted at closing shrink it, and that is the whole mechanism by which APR exceeds the quoted rate.
The worked example
payment(20_000, 0.065, 60) -> 391.32
apr(20_000, 391.32, 60) -> 0.0650 (6.50%)
apr(18_800, 391.32, 60) -> 0.0912 (9.12%)
The same $1,200 fee, at the same rate, priced against different terms:
| Term | Payment | APR, no fee | APR, $1,200 fee | APR, $2,400 fee |
|---|---|---|---|---|
| 36 mo | $612.98 | 6.50% | 10.72% | 15.33% |
| 48 mo | $474.30 | 6.50% | 9.72% | 13.24% |
| 60 mo | $391.32 | 6.50% | 9.12% | 11.98% |
| 72 mo | $336.20 | 6.50% | 8.71% | 11.13% |
Two things show up in that table. A fee hurts more on a short loan, because it is spread over fewer dollars of interest, so the effective price per borrowed dollar rises. At 36 months the same $1,200 fee is worth 4.2 percentage points of APR; at 72 months it is worth 2.2. That is why "no fee, slightly higher rate" is usually the better trade on a short term and often the worse one on a long term.
The other is that APR measures price, not the bill. The 72-month loan has the lowest APR of the fee-paying rows and the highest total interest ($4,206 versus $3,479 at 60 months). APR answers how expensive each dollar-year of borrowing is, not how much you will pay in total. You need both numbers: APR to compare offers, total cost to decide whether to borrow at all.
Trap two: the flat rate
Some lenders, car finance and personal loans especially, quote a flat rate: interest is charged on the original principal for the whole term, and the monthly payment is (principal + principal * rate * years) / months.
6.5% flat on $20,000 over 60 months
interest = 20,000 * 0.065 * 5 = $6,500
payment = 26,500 / 60 = $441.67
apr(20_000, 441.67, 60) = 11.68%
The same "6.5%" is nearly twice as expensive as a 6.5% reducing-balance loan, because flat-rate interest ignores that the balance falls every month. Add the $1,200 fee and the APR reaches 14.43%. Any quote expressed as a flat rate has to be converted before it can be compared with anything, and the conversion is the same twenty lines above.
What APR leaves out
APR is a good comparator with a narrow definition. It does not include:
- Late fees and penalty rates, which are contingent and therefore outside the finance charge.
- Optional products such as payment protection, gap insurance and extended warranties, unless they are required to get the loan.
- Rate resets on variable loans. The APR of an adjustable-rate mortgage is computed from the initial rate and an assumed schedule; it is not a prediction.
- Prepayment. The calculation assumes you hold the loan to maturity. Pay it off early and the fee is spread over a shorter period, which raises the effective cost above the quoted APR.
It also varies by jurisdiction. Reg Z in the US, the APRC under the EU Consumer Credit Directive and the UK's APR all use slightly different fee baskets and rounding rules. The core identity, net proceeds equal the present value of payments, is the same everywhere.
Checking a real quote
To run a quote through this without writing code, the APR calculator on Finance Calc Kit takes the loan amount, rate, term and fees and prints the APR together with the formula and each step of the cash-flow table; the EMI calculator gives the payment and amortisation schedule. Both run client-side, so the numbers stay in the browser, and neither asks for an account. I build Finance Calc Kit, and the code above is the same maths the calculator runs, so you can check it against your own numbers.
The rule I use on any loan offer: compare APR first, because it is the only figure that prices fees; compare total payments second, because a lower APR over a longer term can still cost more; then go back to the fee list and ask which fees you can negotiate away. Every dollar removed from the fee moves the APR down more than a dollar of rate does.
If you want to check the implementation against your own loan, the function is short enough to paste into a REPL. If the APR a lender quotes you disagrees with the one it returns, ask which fees they left out of the finance charge.
Top comments (0)