[{"data":1,"prerenderedAt":2538},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel":3,"surround:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel":2503,"ancestors:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel":2512,"children:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel":2517,"related:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel":2518},{"id":4,"title":5,"body":6,"dateModified":2469,"datePublished":2469,"description":2470,"extension":2471,"faq":2472,"keyword":2481,"kind":2482,"meta":2483,"navigation":407,"path":2492,"seo":2493,"slug":2496,"stem":2497,"topics":2498,"type":2501,"__hash__":2502},"docs\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel\u002Findex.md","Build a Loan Amortization Schedule in Excel with Python",{"type":7,"value":8,"toc":2457},"minimark",[9,32,37,68,71,215,219,238,374,377,381,384,802,805,811,944,948,955,1119,1125,1129,1132,1299,1405,1409,1412,2026,2039,2076,2130,2331,2339,2343,2385,2389,2392,2396,2402,2408,2414,2420,2424,2453],[10,11,12,13,17,18,21,22,25,26,31],"p",{},"An amortization schedule is the table every loan comes with: one row per payment, showing how much of it is interest, how much reduces the balance, and what is left owed. Finance teams build them for equipment loans, leases and intercompany lending; operations teams build them to compare financing offers. Excel's ",[14,15,16],"code",{},"PMT",", ",[14,19,20],{},"IPMT"," and ",[14,23,24],{},"PPMT"," functions make a schedule easy to sketch but tricky to get exactly right: payments are rounded to cents, so the last one never quite clears the balance, and an overpayment changes every row after it. Building the schedule in Python makes those rules explicit, and writing it to Excel with live formulas lets the reader change the rate and watch it recalculate. This page is part of ",[27,28,30],"a",{"href":29},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002F","Building Finance and Operations Reports",".",[33,34,36],"h2",{"id":35},"prerequisites","Prerequisites",[38,39,44],"pre",{"className":40,"code":41,"language":42,"meta":43,"style":43},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas xlsxwriter openpyxl\n","bash","",[14,45,46],{"__ignoreMap":43},[47,48,51,55,59,62,65],"span",{"class":49,"line":50},"line",1,[47,52,54],{"class":53},"sMTad","pip",[47,56,58],{"class":57},"srMev"," install",[47,60,61],{"class":57}," pandas",[47,63,64],{"class":57}," xlsxwriter",[47,66,67],{"class":57}," openpyxl\n",[10,69,70],{},"The example is a 24,000 equipment loan at 6.9% a year, repaid monthly over four years, with the first payment on 1 November 2026. Interest is charged monthly at one twelfth of the annual rate on the outstanding balance — the usual convention for such loans; mortgages in some countries compound differently, and the loan agreement always wins.",[72,73,81,82,81,86,81,90,81,98,81,108,81,119,81,126,81,131,81,137,81,142,81,147,81,154,81,159,81,162,81,166,81,169,81,173,81,180,81,185,81,188,81,192,81,195,81,200,81,207,81,212],"svg",{"viewBox":74,"role":75,"ariaLabelledBy":76,"xmlns":79,"style":80},"4 0 752 125","img",[77,78],"am-flow-t","am-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[83,84,85],"title",{"id":77},"One row of the schedule",[87,88,89],"desc",{"id":78},"Each month the interest is the balance times the monthly rate, the principal is the payment minus interest, and the new balance is the old balance minus principal, which feeds the next row.",[91,92],"rect",{"x":93,"y":94,"width":95,"height":96,"fill":97},"4","0","752","125","#ffffff",[91,99],{"x":100,"y":101,"width":102,"height":103,"rx":104,"fill":105,"stroke":106,"style":107},"20.0","16.0","153.0","93.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[109,110,112],"g",{"transform":111},"translate(84.5,29.0) scale(1.000)",[113,114],"path",{"d":115,"fill":116,"stroke":117,"style":118},"M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3z M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6 M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[120,121,125],"text",{"x":122,"y":123,"style":124},"96.5","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Opening balance",[120,127,130],{"x":122,"y":128,"style":129},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","from the row above",[49,132],{"x1":133,"y1":134,"x2":135,"y2":134,"stroke":136,"style":107},"178.0","62.5","200.0","var(--brand,#5b5cf0)",[138,139],"polygon",{"points":140,"fill":141},"204.0,62.5 195.0,67.5 195.0,57.5","#5b5cf0",[91,143],{"x":144,"y":101,"width":102,"height":103,"rx":104,"fill":145,"stroke":146,"style":107},"209.0","#fdefd8","var(--gold,#b4740a)",[109,148,150],{"transform":149},"translate(273.5,29.0) scale(1.000)",[113,151],{"d":152,"fill":116,"stroke":153,"style":118},"M12 9a3 3 0 1 0 .01 0z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1","var(--gold-ink,#7a4e06)",[120,155,158],{"x":156,"y":123,"style":157},"285.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Interest",[120,160,161],{"x":156,"y":128,"style":129},"balance × 6.9% \u002F 12",[49,163],{"x1":164,"y1":134,"x2":165,"y2":134,"stroke":136,"style":107},"367.0","389.0",[138,167],{"points":168,"fill":141},"393.0,62.5 384.0,67.5 384.0,57.5",[91,170],{"x":171,"y":101,"width":102,"height":103,"rx":104,"fill":172,"stroke":136,"style":107},"398.0","#f0f4ff",[109,174,176],{"transform":175},"translate(462.5,29.0) scale(1.000)",[113,177],{"d":178,"fill":116,"stroke":179,"style":118},"M5 12.5l4.5 4.5L19 7","var(--brand-strong,#4338ca)",[120,181,184],{"x":182,"y":123,"style":183},"474.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Principal",[120,186,187],{"x":182,"y":128,"style":129},"payment − interest",[49,189],{"x1":190,"y1":134,"x2":191,"y2":134,"stroke":136,"style":107},"556.0","578.0",[138,193],{"points":194,"fill":141},"582.0,62.5 573.0,67.5 573.0,57.5",[91,196],{"x":197,"y":101,"width":102,"height":103,"rx":104,"fill":198,"stroke":199,"style":107},"587.0","#d9f4f1","var(--teal,#0f9488)",[109,201,203],{"transform":202},"translate(651.5,29.0) scale(1.000)",[113,204],{"d":205,"fill":116,"stroke":206,"style":118},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--teal-ink,#0b6157)",[120,208,211],{"x":209,"y":123,"style":210},"663.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Closing balance",[120,213,214],{"x":209,"y":128,"style":129},"balance − principal",[33,216,218],{"id":217},"step-1-calculate-the-payment","Step 1 — Calculate the payment",[10,220,221,222,226,227,230,231,234,235,237],{},"A fixed payment that repays a loan over ",[223,224,225],"em",{},"n"," periods at periodic rate ",[223,228,229],{},"r"," is the annuity formula — the same thing Excel's ",[14,232,233],{},"PMT(rate, nper, pv)"," computes, without the sign convention that makes ",[14,236,16],{}," return a negative number:",[38,239,243],{"className":240,"code":241,"language":242,"meta":43,"style":43},"language-python shiki shiki-themes github-light github-dark-high-contrast","principal, annual_rate, months = 24_000, 0.069, 48\nr = annual_rate \u002F 12\npayment = round(principal * r \u002F (1 - (1 + r) ** -months), 2)\nprint(f\"monthly payment: {payment:,.2f}\")\n","python",[14,244,245,269,286,341],{"__ignoreMap":43},[47,246,247,251,255,259,261,264,266],{"class":49,"line":50},[47,248,250],{"class":249},"skGVy","principal, annual_rate, months ",[47,252,254],{"class":253},"s-kum","=",[47,256,258],{"class":257},"sP0c6"," 24_000",[47,260,17],{"class":249},[47,262,263],{"class":257},"0.069",[47,265,17],{"class":249},[47,267,268],{"class":257},"48\n",[47,270,272,275,277,280,283],{"class":49,"line":271},2,[47,273,274],{"class":249},"r ",[47,276,254],{"class":253},[47,278,279],{"class":249}," annual_rate ",[47,281,282],{"class":253},"\u002F",[47,284,285],{"class":257}," 12\n",[47,287,289,292,294,297,300,303,306,308,311,314,317,319,321,324,327,330,332,335,338],{"class":49,"line":288},3,[47,290,291],{"class":249},"payment ",[47,293,254],{"class":253},[47,295,296],{"class":257}," round",[47,298,299],{"class":249},"(principal ",[47,301,302],{"class":253},"*",[47,304,305],{"class":249}," r ",[47,307,282],{"class":253},[47,309,310],{"class":249}," (",[47,312,313],{"class":257},"1",[47,315,316],{"class":253}," -",[47,318,310],{"class":249},[47,320,313],{"class":257},[47,322,323],{"class":253}," +",[47,325,326],{"class":249}," r) ",[47,328,329],{"class":253},"**",[47,331,316],{"class":253},[47,333,334],{"class":249},"months), ",[47,336,337],{"class":257},"2",[47,339,340],{"class":249},")\n",[47,342,344,347,350,353,356,360,363,366,369,372],{"class":49,"line":343},4,[47,345,346],{"class":257},"print",[47,348,349],{"class":249},"(",[47,351,352],{"class":253},"f",[47,354,355],{"class":57},"\"monthly payment: ",[47,357,359],{"class":358},"sSjpA","{",[47,361,362],{"class":249},"payment",[47,364,365],{"class":253},":,.2f",[47,367,368],{"class":358},"}",[47,370,371],{"class":57},"\"",[47,373,340],{"class":249},[10,375,376],{},"The payment is rounded to cents because nobody pays 573.6046… a month. That rounding is tiny, but over 48 months it adds up to a few cents of difference in the final balance, which the schedule has to absorb somewhere — normally in the last payment.",[33,378,380],{"id":379},"step-2-build-the-schedule-row-by-row","Step 2 — Build the schedule row by row",[10,382,383],{},"Each row depends on the balance left by the previous one, so build the schedule with a loop. Round interest to cents each month, as a lender's system does, and make the last payment whatever clears the balance:",[38,385,387],{"className":240,"code":386,"language":242,"meta":43,"style":43},"import pandas as pd\n\ndef schedule(principal, r, months, payment, first=\"2026-11-01\", extra=0.0):\n    rows, balance, start = [], principal, pd.Timestamp(first)\n    for k in range(1, months + 1):\n        interest = round(balance * r, 2)\n        paid_off = min(payment + extra - interest, balance)\n        rows.append({\"No\": k, \"Date\": start + pd.DateOffset(months=k - 1),\n                     \"Opening\": balance, \"Payment\": round(interest + paid_off, 2),\n                     \"Interest\": interest, \"Principal\": round(paid_off, 2)})\n        balance = round(balance - paid_off, 2)\n        rows[-1][\"Closing\"] = balance\n        if balance == 0:\n            break\n    return pd.DataFrame(rows)\n\nplan = schedule(principal, r, months, payment)\nprint(plan.head(3).to_string(index=False))\nprint(plan.tail(2).to_string(index=False))\nprint(f\"total paid {plan['Payment'].sum():,.2f}, of which interest {plan['Interest'].sum():,.2f}\")\n",[14,388,389,403,409,437,447,477,499,524,563,593,617,637,661,679,685,694,699,710,735,755],{"__ignoreMap":43},[47,390,391,394,397,400],{"class":49,"line":50},[47,392,393],{"class":253},"import",[47,395,396],{"class":249}," pandas ",[47,398,399],{"class":253},"as",[47,401,402],{"class":249}," pd\n",[47,404,405],{"class":49,"line":271},[47,406,408],{"emptyLinePlaceholder":407},true,"\n",[47,410,411,414,418,421,423,426,429,431,434],{"class":49,"line":288},[47,412,413],{"class":253},"def",[47,415,417],{"class":416},"s_Opv"," schedule",[47,419,420],{"class":249},"(principal, r, months, payment, first",[47,422,254],{"class":253},[47,424,425],{"class":57},"\"2026-11-01\"",[47,427,428],{"class":249},", extra",[47,430,254],{"class":253},[47,432,433],{"class":257},"0.0",[47,435,436],{"class":249},"):\n",[47,438,439,442,444],{"class":49,"line":343},[47,440,441],{"class":249},"    rows, balance, start ",[47,443,254],{"class":253},[47,445,446],{"class":249}," [], principal, pd.Timestamp(first)\n",[47,448,450,453,456,459,462,464,466,469,472,475],{"class":49,"line":449},5,[47,451,452],{"class":253},"    for",[47,454,455],{"class":249}," k ",[47,457,458],{"class":253},"in",[47,460,461],{"class":257}," range",[47,463,349],{"class":249},[47,465,313],{"class":257},[47,467,468],{"class":249},", months ",[47,470,471],{"class":253},"+",[47,473,474],{"class":257}," 1",[47,476,436],{"class":249},[47,478,480,483,485,487,490,492,495,497],{"class":49,"line":479},6,[47,481,482],{"class":249},"        interest ",[47,484,254],{"class":253},[47,486,296],{"class":257},[47,488,489],{"class":249},"(balance ",[47,491,302],{"class":253},[47,493,494],{"class":249}," r, ",[47,496,337],{"class":257},[47,498,340],{"class":249},[47,500,502,505,507,510,513,515,518,521],{"class":49,"line":501},7,[47,503,504],{"class":249},"        paid_off ",[47,506,254],{"class":253},[47,508,509],{"class":257}," min",[47,511,512],{"class":249},"(payment ",[47,514,471],{"class":253},[47,516,517],{"class":249}," extra ",[47,519,520],{"class":253},"-",[47,522,523],{"class":249}," interest, balance)\n",[47,525,527,530,533,536,539,542,544,547,551,553,556,558,560],{"class":49,"line":526},8,[47,528,529],{"class":249},"        rows.append({",[47,531,532],{"class":57},"\"No\"",[47,534,535],{"class":249},": k, ",[47,537,538],{"class":57},"\"Date\"",[47,540,541],{"class":249},": start ",[47,543,471],{"class":253},[47,545,546],{"class":249}," pd.DateOffset(",[47,548,550],{"class":549},"sa561","months",[47,552,254],{"class":253},[47,554,555],{"class":249},"k ",[47,557,520],{"class":253},[47,559,474],{"class":257},[47,561,562],{"class":249},"),\n",[47,564,566,569,572,575,578,581,584,586,589,591],{"class":49,"line":565},9,[47,567,568],{"class":57},"                     \"Opening\"",[47,570,571],{"class":249},": balance, ",[47,573,574],{"class":57},"\"Payment\"",[47,576,577],{"class":249},": ",[47,579,580],{"class":257},"round",[47,582,583],{"class":249},"(interest ",[47,585,471],{"class":253},[47,587,588],{"class":249}," paid_off, ",[47,590,337],{"class":257},[47,592,562],{"class":249},[47,594,596,599,602,605,607,609,612,614],{"class":49,"line":595},10,[47,597,598],{"class":57},"                     \"Interest\"",[47,600,601],{"class":249},": interest, ",[47,603,604],{"class":57},"\"Principal\"",[47,606,577],{"class":249},[47,608,580],{"class":257},[47,610,611],{"class":249},"(paid_off, ",[47,613,337],{"class":257},[47,615,616],{"class":249},")})\n",[47,618,620,623,625,627,629,631,633,635],{"class":49,"line":619},11,[47,621,622],{"class":249},"        balance ",[47,624,254],{"class":253},[47,626,296],{"class":257},[47,628,489],{"class":249},[47,630,520],{"class":253},[47,632,588],{"class":249},[47,634,337],{"class":257},[47,636,340],{"class":249},[47,638,640,643,645,647,650,653,656,658],{"class":49,"line":639},12,[47,641,642],{"class":249},"        rows[",[47,644,520],{"class":253},[47,646,313],{"class":257},[47,648,649],{"class":249},"][",[47,651,652],{"class":57},"\"Closing\"",[47,654,655],{"class":249},"] ",[47,657,254],{"class":253},[47,659,660],{"class":249}," balance\n",[47,662,664,667,670,673,676],{"class":49,"line":663},13,[47,665,666],{"class":253},"        if",[47,668,669],{"class":249}," balance ",[47,671,672],{"class":253},"==",[47,674,675],{"class":257}," 0",[47,677,678],{"class":249},":\n",[47,680,682],{"class":49,"line":681},14,[47,683,684],{"class":253},"            break\n",[47,686,688,691],{"class":49,"line":687},15,[47,689,690],{"class":253},"    return",[47,692,693],{"class":249}," pd.DataFrame(rows)\n",[47,695,697],{"class":49,"line":696},16,[47,698,408],{"emptyLinePlaceholder":407},[47,700,702,705,707],{"class":49,"line":701},17,[47,703,704],{"class":249},"plan ",[47,706,254],{"class":253},[47,708,709],{"class":249}," schedule(principal, r, months, payment)\n",[47,711,713,715,718,721,724,727,729,732],{"class":49,"line":712},18,[47,714,346],{"class":257},[47,716,717],{"class":249},"(plan.head(",[47,719,720],{"class":257},"3",[47,722,723],{"class":249},").to_string(",[47,725,726],{"class":549},"index",[47,728,254],{"class":253},[47,730,731],{"class":257},"False",[47,733,734],{"class":249},"))\n",[47,736,738,740,743,745,747,749,751,753],{"class":49,"line":737},19,[47,739,346],{"class":257},[47,741,742],{"class":249},"(plan.tail(",[47,744,337],{"class":257},[47,746,723],{"class":249},[47,748,726],{"class":549},[47,750,254],{"class":253},[47,752,731],{"class":257},[47,754,734],{"class":249},[47,756,758,760,762,764,767,769,772,775,778,780,782,785,787,789,792,794,796,798,800],{"class":49,"line":757},20,[47,759,346],{"class":257},[47,761,349],{"class":249},[47,763,352],{"class":253},[47,765,766],{"class":57},"\"total paid ",[47,768,359],{"class":358},[47,770,771],{"class":249},"plan[",[47,773,774],{"class":57},"'Payment'",[47,776,777],{"class":249},"].sum()",[47,779,365],{"class":253},[47,781,368],{"class":358},[47,783,784],{"class":57},", of which interest ",[47,786,359],{"class":358},[47,788,771],{"class":249},[47,790,791],{"class":57},"'Interest'",[47,793,777],{"class":249},[47,795,365],{"class":253},[47,797,368],{"class":358},[47,799,371],{"class":57},[47,801,340],{"class":249},[10,803,804],{},"The first payment is mostly interest — 138.00 of the 573.60 — and the share shifts towards principal every month, because interest is charged on a shrinking balance. The final payment is 573.40, twenty cents less than the others: the rounding difference, absorbed in the last row so the closing balance is exactly zero. Over the four years the loan costs 3,532.60 in interest.",[10,806,807,810],{},[14,808,809],{},"pd.DateOffset(months=...)"," moves by calendar months, so a loan starting on the 31st lands on the last day of shorter months rather than drifting into the next month.",[72,812,81,818,81,821,81,824,81,828,81,834,81,839,81,842,81,845,81,851,81,856,81,860,81,864,81,867,81,871,81,874,81,878,81,881,81,886,81,890,81,894,81,898,81,902,81,908,81,912,81,917,81,920,81,925,81,928,81,933,81,936,81,941],{"viewBox":813,"role":75,"ariaLabelledBy":814,"xmlns":79,"style":817},"4 0 748 260",[815,816],"am-split-t","am-split-d","width:100%;max-width:748px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[83,819,820],{"id":815},"How each payment splits between interest and principal",[87,822,823],{"id":816},"Stacked columns for payments 1, 12, 24, 36 and 48. Interest falls from 138 to about 3 while principal rises from about 436 to about 570; the total payment stays at 573.60 until the slightly smaller final payment.",[91,825],{"x":93,"y":94,"width":826,"height":827,"fill":97},"748","260",[91,829],{"x":830,"y":831,"width":832,"height":832,"rx":720,"fill":172,"stroke":136,"style":833},"64.0","18.0","14.0","stroke-width:1.5px",[120,835,184],{"x":836,"y":837,"style":838},"84.0","29.5","font-size:12px;font-weight:600;fill:var(--text,#172033);text-anchor:start",[91,840],{"x":841,"y":831,"width":832,"height":832,"rx":720,"fill":145,"stroke":146,"style":833},"166.1",[120,843,158],{"x":844,"y":837,"style":838},"186.1",[49,846],{"x1":847,"y1":848,"x2":849,"y2":848,"stroke":106,"style":850},"54.0","222.0","736.0","stroke-width:1px",[120,852,94],{"x":853,"y":854,"style":855},"31.0","226.0","font-size:11px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle",[49,857],{"x1":847,"y1":858,"x2":849,"y2":858,"stroke":106,"style":859},"165.3","stroke-width:1px;stroke-dasharray:5 4",[120,861,863],{"x":853,"y":862,"style":855},"169.3","200",[49,865],{"x1":847,"y1":866,"x2":849,"y2":866,"stroke":106,"style":859},"108.7",[120,868,870],{"x":853,"y":869,"style":855},"112.7","400",[49,872],{"x1":847,"y1":873,"x2":849,"y2":873,"stroke":106,"style":859},"52.0",[120,875,877],{"x":853,"y":876,"style":855},"56.0","600",[49,879],{"x1":847,"y1":848,"x2":849,"y2":848,"stroke":880,"style":833},"var(--muted,#5b6780)",[120,882,885],{"x":883,"y":884,"style":129},"122.2","240.0","#1",[120,887,889],{"x":888,"y":884,"style":129},"258.6","#12",[120,891,893],{"x":892,"y":884,"style":129},"395.0","#24",[120,895,897],{"x":896,"y":884,"style":129},"531.4","#36",[120,899,901],{"x":900,"y":884,"style":129},"667.8","#48",[91,903],{"x":904,"y":905,"width":906,"height":907,"rx":720,"fill":172,"stroke":136,"style":833},"93.2","98.6","58.0","123.4",[91,909],{"x":904,"y":910,"width":906,"height":911,"rx":720,"fill":145,"stroke":146,"style":833},"59.5","39.1",[91,913],{"x":914,"y":915,"width":906,"height":916,"rx":720,"fill":172,"stroke":136,"style":833},"229.6","90.5","131.5",[91,918],{"x":914,"y":910,"width":906,"height":919,"rx":720,"fill":145,"stroke":146,"style":833},"31.1",[91,921],{"x":922,"y":923,"width":906,"height":924,"rx":720,"fill":172,"stroke":136,"style":833},"366.0","81.2","140.8",[91,926],{"x":922,"y":910,"width":906,"height":927,"rx":720,"fill":145,"stroke":146,"style":833},"21.7",[91,929],{"x":930,"y":931,"width":906,"height":932,"rx":720,"fill":172,"stroke":136,"style":833},"502.4","71.2","150.8",[91,934],{"x":930,"y":910,"width":906,"height":935,"rx":720,"fill":145,"stroke":146,"style":833},"11.7",[91,937],{"x":938,"y":939,"width":906,"height":940,"rx":720,"fill":172,"stroke":136,"style":833},"638.8","60.5","161.5",[91,942],{"x":938,"y":910,"width":906,"height":943,"rx":720,"fill":145,"stroke":146,"style":833},"0.9",[33,945,947],{"id":946},"step-3-overpayments","Step 3 — Overpayments",[10,949,950,951,954],{},"An extra 100 a month goes straight to principal, so the balance falls faster, less interest accrues, and the loan ends early. The same function handles it through the ",[14,952,953],{},"extra"," argument:",[38,956,958],{"className":240,"code":957,"language":242,"meta":43,"style":43},"fast = schedule(principal, r, months, payment, extra=100)\nsaved = plan[\"Interest\"].sum() - fast[\"Interest\"].sum()\nprint(f\"{len(fast)} payments instead of {len(plan)}, last on {fast['Date'].iloc[-1]:%d %b %Y}\")\nprint(f\"interest {fast['Interest'].sum():,.2f} — saving {saved:,.2f}\")\n",[14,959,960,979,1005,1080],{"__ignoreMap":43},[47,961,962,965,967,970,972,974,977],{"class":49,"line":50},[47,963,964],{"class":249},"fast ",[47,966,254],{"class":253},[47,968,969],{"class":249}," schedule(principal, r, months, payment, ",[47,971,953],{"class":549},[47,973,254],{"class":253},[47,975,976],{"class":257},"100",[47,978,340],{"class":249},[47,980,981,984,986,989,992,995,997,1000,1002],{"class":49,"line":271},[47,982,983],{"class":249},"saved ",[47,985,254],{"class":253},[47,987,988],{"class":249}," plan[",[47,990,991],{"class":57},"\"Interest\"",[47,993,994],{"class":249},"].sum() ",[47,996,520],{"class":253},[47,998,999],{"class":249}," fast[",[47,1001,991],{"class":57},[47,1003,1004],{"class":249},"].sum()\n",[47,1006,1007,1009,1011,1013,1015,1017,1020,1023,1025,1028,1030,1032,1035,1037,1040,1042,1045,1048,1051,1053,1055,1058,1061,1064,1066,1069,1071,1074,1076,1078],{"class":49,"line":288},[47,1008,346],{"class":257},[47,1010,349],{"class":249},[47,1012,352],{"class":253},[47,1014,371],{"class":57},[47,1016,359],{"class":358},[47,1018,1019],{"class":257},"len",[47,1021,1022],{"class":249},"(fast)",[47,1024,368],{"class":358},[47,1026,1027],{"class":57}," payments instead of ",[47,1029,359],{"class":358},[47,1031,1019],{"class":257},[47,1033,1034],{"class":249},"(plan)",[47,1036,368],{"class":358},[47,1038,1039],{"class":57},", last on ",[47,1041,359],{"class":358},[47,1043,1044],{"class":249},"fast[",[47,1046,1047],{"class":57},"'Date'",[47,1049,1050],{"class":249},"].iloc[",[47,1052,520],{"class":253},[47,1054,313],{"class":257},[47,1056,1057],{"class":249},"]:",[47,1059,1060],{"class":253},"%",[47,1062,1063],{"class":249},"d ",[47,1065,1060],{"class":253},[47,1067,1068],{"class":249},"b ",[47,1070,1060],{"class":253},[47,1072,1073],{"class":249},"Y",[47,1075,368],{"class":358},[47,1077,371],{"class":57},[47,1079,340],{"class":249},[47,1081,1082,1084,1086,1088,1091,1093,1095,1097,1099,1101,1103,1106,1108,1111,1113,1115,1117],{"class":49,"line":343},[47,1083,346],{"class":257},[47,1085,349],{"class":249},[47,1087,352],{"class":253},[47,1089,1090],{"class":57},"\"interest ",[47,1092,359],{"class":358},[47,1094,1044],{"class":249},[47,1096,791],{"class":57},[47,1098,777],{"class":249},[47,1100,365],{"class":253},[47,1102,368],{"class":358},[47,1104,1105],{"class":57}," — saving ",[47,1107,359],{"class":358},[47,1109,1110],{"class":249},"saved",[47,1112,365],{"class":253},[47,1114,368],{"class":358},[47,1116,371],{"class":57},[47,1118,340],{"class":249},[10,1120,1121,1122,1124],{},"Paying 673.60 instead of 573.60 clears the loan in 40 months instead of 48 and saves about 600 in interest. Comparing offers or overpayment strategies becomes a loop over parameters — the kind of table that is tedious to maintain by copying sheets in Excel. One-off lump sums work the same way with an ",[14,1123,953],{}," that varies by month; pass a dictionary of month number to amount and look it up inside the loop.",[33,1126,1128],{"id":1127},"step-4-summarise-and-chart-the-balance","Step 4 — Summarise and chart the balance",[10,1130,1131],{},"Readers want four numbers before the 48 rows: the payment, the total interest, the total repaid and the payoff date. Put them above the schedule, and add a chart of the balance:",[38,1133,1135],{"className":240,"code":1134,"language":242,"meta":43,"style":43},"summary = pd.Series({\n    \"Loan amount\": principal,\n    \"Annual rate\": annual_rate,\n    \"Monthly payment\": payment,\n    \"Number of payments\": len(plan),\n    \"Total interest\": round(plan[\"Interest\"].sum(), 2),\n    \"Total repaid\": round(plan[\"Payment\"].sum(), 2),\n    \"Final payment date\": plan[\"Date\"].iloc[-1].date(),\n})\nprint(summary.to_string())\nprint(plan.loc[[11, 23, 35, 47], [\"No\", \"Closing\"]].to_string(index=False))\n",[14,1136,1137,1147,1155,1163,1171,1183,1204,1223,1242,1247,1254],{"__ignoreMap":43},[47,1138,1139,1142,1144],{"class":49,"line":50},[47,1140,1141],{"class":249},"summary ",[47,1143,254],{"class":253},[47,1145,1146],{"class":249}," pd.Series({\n",[47,1148,1149,1152],{"class":49,"line":271},[47,1150,1151],{"class":57},"    \"Loan amount\"",[47,1153,1154],{"class":249},": principal,\n",[47,1156,1157,1160],{"class":49,"line":288},[47,1158,1159],{"class":57},"    \"Annual rate\"",[47,1161,1162],{"class":249},": annual_rate,\n",[47,1164,1165,1168],{"class":49,"line":343},[47,1166,1167],{"class":57},"    \"Monthly payment\"",[47,1169,1170],{"class":249},": payment,\n",[47,1172,1173,1176,1178,1180],{"class":49,"line":449},[47,1174,1175],{"class":57},"    \"Number of payments\"",[47,1177,577],{"class":249},[47,1179,1019],{"class":257},[47,1181,1182],{"class":249},"(plan),\n",[47,1184,1185,1188,1190,1192,1195,1197,1200,1202],{"class":49,"line":479},[47,1186,1187],{"class":57},"    \"Total interest\"",[47,1189,577],{"class":249},[47,1191,580],{"class":257},[47,1193,1194],{"class":249},"(plan[",[47,1196,991],{"class":57},[47,1198,1199],{"class":249},"].sum(), ",[47,1201,337],{"class":257},[47,1203,562],{"class":249},[47,1205,1206,1209,1211,1213,1215,1217,1219,1221],{"class":49,"line":501},[47,1207,1208],{"class":57},"    \"Total repaid\"",[47,1210,577],{"class":249},[47,1212,580],{"class":257},[47,1214,1194],{"class":249},[47,1216,574],{"class":57},[47,1218,1199],{"class":249},[47,1220,337],{"class":257},[47,1222,562],{"class":249},[47,1224,1225,1228,1231,1233,1235,1237,1239],{"class":49,"line":526},[47,1226,1227],{"class":57},"    \"Final payment date\"",[47,1229,1230],{"class":249},": plan[",[47,1232,538],{"class":57},[47,1234,1050],{"class":249},[47,1236,520],{"class":253},[47,1238,313],{"class":257},[47,1240,1241],{"class":249},"].date(),\n",[47,1243,1244],{"class":49,"line":565},[47,1245,1246],{"class":249},"})\n",[47,1248,1249,1251],{"class":49,"line":595},[47,1250,346],{"class":257},[47,1252,1253],{"class":249},"(summary.to_string())\n",[47,1255,1256,1258,1261,1264,1266,1269,1271,1274,1276,1279,1282,1284,1286,1288,1291,1293,1295,1297],{"class":49,"line":619},[47,1257,346],{"class":257},[47,1259,1260],{"class":249},"(plan.loc[[",[47,1262,1263],{"class":257},"11",[47,1265,17],{"class":249},[47,1267,1268],{"class":257},"23",[47,1270,17],{"class":249},[47,1272,1273],{"class":257},"35",[47,1275,17],{"class":249},[47,1277,1278],{"class":257},"47",[47,1280,1281],{"class":249},"], [",[47,1283,532],{"class":57},[47,1285,17],{"class":249},[47,1287,652],{"class":57},[47,1289,1290],{"class":249},"]].to_string(",[47,1292,726],{"class":549},[47,1294,254],{"class":253},[47,1296,731],{"class":257},[47,1298,734],{"class":249},[72,1300,81,1305,81,1308,81,1311,81,1314,81,1318,81,1322,81,1325,81,1329,81,1332,81,1336,81,1339,81,1343,81,1345,81,1350,81,1354,81,1358,81,1362,81,1366,81,1370,81,1374,81,1377,81,1380,81,1383,81,1385,81,1390,81,1394,81,1398,81,1402],{"viewBox":1301,"role":75,"ariaLabelledBy":1302,"xmlns":79,"style":817},"4 0 748 232",[1303,1304],"am-balance-t","am-balance-d",[83,1306,1307],{"id":1303},"Outstanding balance over the life of the loan",[87,1309,1310],{"id":1304},"Line chart of the balance at the start and after each year. 24,000 at the start, about 18,600 after one year, 12,800 after two, 6,600 after three and zero after four. The line curves slightly because more principal is repaid each year.",[91,1312],{"x":93,"y":94,"width":826,"height":1313,"fill":97},"232",[49,1315],{"x1":1316,"y1":1317,"x2":849,"y2":1317,"stroke":106,"style":850},"52.8","194.0",[120,1319,94],{"x":1320,"y":1321,"style":855},"30.4","198.0",[49,1323],{"x1":1316,"y1":1324,"x2":849,"y2":1324,"stroke":106,"style":859},"137.3",[120,1326,1328],{"x":1320,"y":1327,"style":855},"141.3","10k",[49,1330],{"x1":1316,"y1":1331,"x2":849,"y2":1331,"stroke":106,"style":859},"80.7",[120,1333,1335],{"x":1320,"y":1334,"style":855},"84.7","20k",[49,1337],{"x1":1316,"y1":1338,"x2":849,"y2":1338,"stroke":106,"style":859},"24.0",[120,1340,1342],{"x":1320,"y":1341,"style":855},"28.0","30k",[49,1344],{"x1":1316,"y1":1317,"x2":849,"y2":1317,"stroke":880,"style":833},[120,1346,1349],{"x":1347,"y":1348,"style":129},"121.1","212.0","Start",[120,1351,1353],{"x":1352,"y":1348,"style":129},"257.7","Year 1",[120,1355,1357],{"x":1356,"y":1348,"style":129},"394.4","Year 2",[120,1359,1361],{"x":1360,"y":1348,"style":129},"531.0","Year 3",[120,1363,1365],{"x":1364,"y":1348,"style":129},"667.7","Year 4",[113,1367],{"d":1368,"fill":116,"stroke":136,"style":1369},"M121.1,58.0 L257.7,88.6 L394.4,121.3 L531.0,156.4 L667.7,194.0","stroke-width:2.5px;stroke-linejoin:round;stroke-linecap:round",[1371,1372],"circle",{"cx":1347,"cy":906,"r":1373,"fill":97,"stroke":136,"style":107},"4.0",[1371,1375],{"cx":1352,"cy":1376,"r":1373,"fill":97,"stroke":136,"style":107},"88.6",[1371,1378],{"cx":1356,"cy":1379,"r":1373,"fill":97,"stroke":136,"style":107},"121.3",[1371,1381],{"cx":1360,"cy":1382,"r":1373,"fill":97,"stroke":136,"style":107},"156.4",[1371,1384],{"cx":1364,"cy":1317,"r":1373,"fill":97,"stroke":136,"style":107},[120,1386,1389],{"x":1347,"y":1387,"style":1388},"48.0","font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","24k",[120,1391,1393],{"x":1352,"y":1392,"style":1388},"78.6","18.604k",[120,1395,1397],{"x":1356,"y":1396,"style":1388},"111.3","12.824k",[120,1399,1401],{"x":1360,"y":1400,"style":1388},"146.4","6.633k",[120,1403,94],{"x":1364,"y":1404,"style":1388},"184.0",[33,1406,1408],{"id":1407},"step-5-write-a-workbook-with-live-formulas","Step 5 — Write a workbook with live formulas",[10,1410,1411],{},"A schedule written as values is fine for a record. For a model that people will play with, write the inputs in cells and the schedule as formulas that refer to them, so changing the rate recalculates every row:",[38,1413,1415],{"className":240,"code":1414,"language":242,"meta":43,"style":43},"import xlsxwriter\n\nwb = xlsxwriter.Workbook(\"loan_schedule.xlsx\")\nws = wb.add_worksheet(\"Schedule\")\nbold = wb.add_format({\"bold\": True})\nmoney = wb.add_format({\"num_format\": \"#,##0.00\"})\npct = wb.add_format({\"num_format\": \"0.00%\"})\ndate = wb.add_format({\"num_format\": \"dd mmm yyyy\"})\n\ninputs = [(\"Loan amount\", principal, money), (\"Annual rate\", annual_rate, pct), (\"Months\", months, None)]\nfor i, (label, value, fmt) in enumerate(inputs):\n    ws.write(i, 0, label, bold)\n    ws.write(i, 1, value, fmt)\nws.write(3, 0, \"Payment\", bold)\nws.write_formula(3, 1, \"=ROUND(PMT(B2\u002F12,B3,-B1),2)\", money)\nws.write_row(5, 0, [\"No\", \"Date\", \"Opening\", \"Payment\", \"Interest\", \"Principal\", \"Closing\"], bold)\n\nfor k in range(1, months + 1):\n    row = 5 + k                      # zero-based row index; Excel row number is row + 1\n    x = row + 1\n    ws.write(row, 0, k)\n    ws.write_datetime(row, 1, plan[\"Date\"].iloc[k - 1].to_pydatetime(), date)\n    ws.write_formula(row, 2, \"=B1\" if k == 1 else f\"=G{x - 1}\", money)\n    ws.write_formula(row, 4, f\"=ROUND(C{x}*$B$2\u002F12,2)\", money)\n    ws.write_formula(row, 3, f\"=IF(A{x}=$B$3,C{x}+E{x},$B$4)\", money)\n    ws.write_formula(row, 5, f\"=D{x}-E{x}\", money)\n    ws.write_formula(row, 6, f\"=ROUND(C{x}-F{x},2)\", money)\nws.set_column(\"A:A\", 12)\nws.set_column(\"B:G\", 13)\nws.freeze_panes(6, 0)\nwb.close()\nprint(\"written\")\n",[14,1416,1417,1424,1428,1443,1458,1478,1497,1515,1533,1537,1571,1587,1597,1606,1624,1643,1688,1692,1714,1733,1748,1759,1782,1828,1854,1897,1930,1964,1979,1994,2008,2014],{"__ignoreMap":43},[47,1418,1419,1421],{"class":49,"line":50},[47,1420,393],{"class":253},[47,1422,1423],{"class":249}," xlsxwriter\n",[47,1425,1426],{"class":49,"line":271},[47,1427,408],{"emptyLinePlaceholder":407},[47,1429,1430,1433,1435,1438,1441],{"class":49,"line":288},[47,1431,1432],{"class":249},"wb ",[47,1434,254],{"class":253},[47,1436,1437],{"class":249}," xlsxwriter.Workbook(",[47,1439,1440],{"class":57},"\"loan_schedule.xlsx\"",[47,1442,340],{"class":249},[47,1444,1445,1448,1450,1453,1456],{"class":49,"line":343},[47,1446,1447],{"class":249},"ws ",[47,1449,254],{"class":253},[47,1451,1452],{"class":249}," wb.add_worksheet(",[47,1454,1455],{"class":57},"\"Schedule\"",[47,1457,340],{"class":249},[47,1459,1460,1463,1465,1468,1471,1473,1476],{"class":49,"line":449},[47,1461,1462],{"class":249},"bold ",[47,1464,254],{"class":253},[47,1466,1467],{"class":249}," wb.add_format({",[47,1469,1470],{"class":57},"\"bold\"",[47,1472,577],{"class":249},[47,1474,1475],{"class":257},"True",[47,1477,1246],{"class":249},[47,1479,1480,1483,1485,1487,1490,1492,1495],{"class":49,"line":479},[47,1481,1482],{"class":249},"money ",[47,1484,254],{"class":253},[47,1486,1467],{"class":249},[47,1488,1489],{"class":57},"\"num_format\"",[47,1491,577],{"class":249},[47,1493,1494],{"class":57},"\"#,##0.00\"",[47,1496,1246],{"class":249},[47,1498,1499,1502,1504,1506,1508,1510,1513],{"class":49,"line":501},[47,1500,1501],{"class":249},"pct ",[47,1503,254],{"class":253},[47,1505,1467],{"class":249},[47,1507,1489],{"class":57},[47,1509,577],{"class":249},[47,1511,1512],{"class":57},"\"0.00%\"",[47,1514,1246],{"class":249},[47,1516,1517,1520,1522,1524,1526,1528,1531],{"class":49,"line":526},[47,1518,1519],{"class":249},"date ",[47,1521,254],{"class":253},[47,1523,1467],{"class":249},[47,1525,1489],{"class":57},[47,1527,577],{"class":249},[47,1529,1530],{"class":57},"\"dd mmm yyyy\"",[47,1532,1246],{"class":249},[47,1534,1535],{"class":49,"line":565},[47,1536,408],{"emptyLinePlaceholder":407},[47,1538,1539,1542,1544,1547,1550,1553,1556,1559,1562,1565,1568],{"class":49,"line":595},[47,1540,1541],{"class":249},"inputs ",[47,1543,254],{"class":253},[47,1545,1546],{"class":249}," [(",[47,1548,1549],{"class":57},"\"Loan amount\"",[47,1551,1552],{"class":249},", principal, money), (",[47,1554,1555],{"class":57},"\"Annual rate\"",[47,1557,1558],{"class":249},", annual_rate, pct), (",[47,1560,1561],{"class":57},"\"Months\"",[47,1563,1564],{"class":249},", months, ",[47,1566,1567],{"class":257},"None",[47,1569,1570],{"class":249},")]\n",[47,1572,1573,1576,1579,1581,1584],{"class":49,"line":619},[47,1574,1575],{"class":253},"for",[47,1577,1578],{"class":249}," i, (label, value, fmt) ",[47,1580,458],{"class":253},[47,1582,1583],{"class":257}," enumerate",[47,1585,1586],{"class":249},"(inputs):\n",[47,1588,1589,1592,1594],{"class":49,"line":639},[47,1590,1591],{"class":249},"    ws.write(i, ",[47,1593,94],{"class":257},[47,1595,1596],{"class":249},", label, bold)\n",[47,1598,1599,1601,1603],{"class":49,"line":663},[47,1600,1591],{"class":249},[47,1602,313],{"class":257},[47,1604,1605],{"class":249},", value, fmt)\n",[47,1607,1608,1611,1613,1615,1617,1619,1621],{"class":49,"line":681},[47,1609,1610],{"class":249},"ws.write(",[47,1612,720],{"class":257},[47,1614,17],{"class":249},[47,1616,94],{"class":257},[47,1618,17],{"class":249},[47,1620,574],{"class":57},[47,1622,1623],{"class":249},", bold)\n",[47,1625,1626,1629,1631,1633,1635,1637,1640],{"class":49,"line":687},[47,1627,1628],{"class":249},"ws.write_formula(",[47,1630,720],{"class":257},[47,1632,17],{"class":249},[47,1634,313],{"class":257},[47,1636,17],{"class":249},[47,1638,1639],{"class":57},"\"=ROUND(PMT(B2\u002F12,B3,-B1),2)\"",[47,1641,1642],{"class":249},", money)\n",[47,1644,1645,1648,1651,1653,1655,1658,1660,1662,1664,1666,1669,1671,1673,1675,1677,1679,1681,1683,1685],{"class":49,"line":696},[47,1646,1647],{"class":249},"ws.write_row(",[47,1649,1650],{"class":257},"5",[47,1652,17],{"class":249},[47,1654,94],{"class":257},[47,1656,1657],{"class":249},", [",[47,1659,532],{"class":57},[47,1661,17],{"class":249},[47,1663,538],{"class":57},[47,1665,17],{"class":249},[47,1667,1668],{"class":57},"\"Opening\"",[47,1670,17],{"class":249},[47,1672,574],{"class":57},[47,1674,17],{"class":249},[47,1676,991],{"class":57},[47,1678,17],{"class":249},[47,1680,604],{"class":57},[47,1682,17],{"class":249},[47,1684,652],{"class":57},[47,1686,1687],{"class":249},"], bold)\n",[47,1689,1690],{"class":49,"line":701},[47,1691,408],{"emptyLinePlaceholder":407},[47,1693,1694,1696,1698,1700,1702,1704,1706,1708,1710,1712],{"class":49,"line":712},[47,1695,1575],{"class":253},[47,1697,455],{"class":249},[47,1699,458],{"class":253},[47,1701,461],{"class":257},[47,1703,349],{"class":249},[47,1705,313],{"class":257},[47,1707,468],{"class":249},[47,1709,471],{"class":253},[47,1711,474],{"class":257},[47,1713,436],{"class":249},[47,1715,1716,1719,1721,1724,1726,1729],{"class":49,"line":737},[47,1717,1718],{"class":249},"    row ",[47,1720,254],{"class":253},[47,1722,1723],{"class":257}," 5",[47,1725,323],{"class":253},[47,1727,1728],{"class":249}," k                      ",[47,1730,1732],{"class":1731},"s-wDw","# zero-based row index; Excel row number is row + 1\n",[47,1734,1735,1738,1740,1743,1745],{"class":49,"line":757},[47,1736,1737],{"class":249},"    x ",[47,1739,254],{"class":253},[47,1741,1742],{"class":249}," row ",[47,1744,471],{"class":253},[47,1746,1747],{"class":257}," 1\n",[47,1749,1751,1754,1756],{"class":49,"line":1750},21,[47,1752,1753],{"class":249},"    ws.write(row, ",[47,1755,94],{"class":257},[47,1757,1758],{"class":249},", k)\n",[47,1760,1762,1765,1767,1770,1772,1775,1777,1779],{"class":49,"line":1761},22,[47,1763,1764],{"class":249},"    ws.write_datetime(row, ",[47,1766,313],{"class":257},[47,1768,1769],{"class":249},", plan[",[47,1771,538],{"class":57},[47,1773,1774],{"class":249},"].iloc[k ",[47,1776,520],{"class":253},[47,1778,474],{"class":257},[47,1780,1781],{"class":249},"].to_pydatetime(), date)\n",[47,1783,1785,1788,1790,1792,1795,1798,1800,1802,1804,1807,1810,1813,1815,1818,1820,1822,1824,1826],{"class":49,"line":1784},23,[47,1786,1787],{"class":249},"    ws.write_formula(row, ",[47,1789,337],{"class":257},[47,1791,17],{"class":249},[47,1793,1794],{"class":57},"\"=B1\"",[47,1796,1797],{"class":253}," if",[47,1799,455],{"class":249},[47,1801,672],{"class":253},[47,1803,474],{"class":257},[47,1805,1806],{"class":253}," else",[47,1808,1809],{"class":253}," f",[47,1811,1812],{"class":57},"\"=G",[47,1814,359],{"class":358},[47,1816,1817],{"class":249},"x ",[47,1819,520],{"class":253},[47,1821,474],{"class":257},[47,1823,368],{"class":358},[47,1825,371],{"class":57},[47,1827,1642],{"class":249},[47,1829,1831,1833,1835,1837,1839,1842,1844,1847,1849,1852],{"class":49,"line":1830},24,[47,1832,1787],{"class":249},[47,1834,93],{"class":257},[47,1836,17],{"class":249},[47,1838,352],{"class":253},[47,1840,1841],{"class":57},"\"=ROUND(C",[47,1843,359],{"class":358},[47,1845,1846],{"class":249},"x",[47,1848,368],{"class":358},[47,1850,1851],{"class":57},"*$B$2\u002F12,2)\"",[47,1853,1642],{"class":249},[47,1855,1857,1859,1861,1863,1865,1868,1870,1872,1874,1877,1879,1881,1883,1886,1888,1890,1892,1895],{"class":49,"line":1856},25,[47,1858,1787],{"class":249},[47,1860,720],{"class":257},[47,1862,17],{"class":249},[47,1864,352],{"class":253},[47,1866,1867],{"class":57},"\"=IF(A",[47,1869,359],{"class":358},[47,1871,1846],{"class":249},[47,1873,368],{"class":358},[47,1875,1876],{"class":57},"=$B$3,C",[47,1878,359],{"class":358},[47,1880,1846],{"class":249},[47,1882,368],{"class":358},[47,1884,1885],{"class":57},"+E",[47,1887,359],{"class":358},[47,1889,1846],{"class":249},[47,1891,368],{"class":358},[47,1893,1894],{"class":57},",$B$4)\"",[47,1896,1642],{"class":249},[47,1898,1900,1902,1904,1906,1908,1911,1913,1915,1917,1920,1922,1924,1926,1928],{"class":49,"line":1899},26,[47,1901,1787],{"class":249},[47,1903,1650],{"class":257},[47,1905,17],{"class":249},[47,1907,352],{"class":253},[47,1909,1910],{"class":57},"\"=D",[47,1912,359],{"class":358},[47,1914,1846],{"class":249},[47,1916,368],{"class":358},[47,1918,1919],{"class":57},"-E",[47,1921,359],{"class":358},[47,1923,1846],{"class":249},[47,1925,368],{"class":358},[47,1927,371],{"class":57},[47,1929,1642],{"class":249},[47,1931,1933,1935,1938,1940,1942,1944,1946,1948,1950,1953,1955,1957,1959,1962],{"class":49,"line":1932},27,[47,1934,1787],{"class":249},[47,1936,1937],{"class":257},"6",[47,1939,17],{"class":249},[47,1941,352],{"class":253},[47,1943,1841],{"class":57},[47,1945,359],{"class":358},[47,1947,1846],{"class":249},[47,1949,368],{"class":358},[47,1951,1952],{"class":57},"-F",[47,1954,359],{"class":358},[47,1956,1846],{"class":249},[47,1958,368],{"class":358},[47,1960,1961],{"class":57},",2)\"",[47,1963,1642],{"class":249},[47,1965,1967,1970,1973,1975,1977],{"class":49,"line":1966},28,[47,1968,1969],{"class":249},"ws.set_column(",[47,1971,1972],{"class":57},"\"A:A\"",[47,1974,17],{"class":249},[47,1976,104],{"class":257},[47,1978,340],{"class":249},[47,1980,1982,1984,1987,1989,1992],{"class":49,"line":1981},29,[47,1983,1969],{"class":249},[47,1985,1986],{"class":57},"\"B:G\"",[47,1988,17],{"class":249},[47,1990,1991],{"class":257},"13",[47,1993,340],{"class":249},[47,1995,1997,2000,2002,2004,2006],{"class":49,"line":1996},30,[47,1998,1999],{"class":249},"ws.freeze_panes(",[47,2001,1937],{"class":257},[47,2003,17],{"class":249},[47,2005,94],{"class":257},[47,2007,340],{"class":249},[47,2009,2011],{"class":49,"line":2010},31,[47,2012,2013],{"class":249},"wb.close()\n",[47,2015,2017,2019,2021,2024],{"class":49,"line":2016},32,[47,2018,346],{"class":257},[47,2020,349],{"class":249},[47,2022,2023],{"class":57},"\"written\"",[47,2025,340],{"class":249},[10,2027,2028,2029,2031,2032,2034,2035,2038],{},"The payment formula uses ",[14,2030,16],{}," with a negative loan amount, which flips Excel's sign convention so the payment shows as a positive number. The last row's payment is the opening balance plus interest, mirroring the Python rule that the final payment clears the loan. To check the formulas, let LibreOffice recalculate a copy and compare it with the Python schedule. There is a catch: xlsxwriter stores ",[14,2033,94],{}," as the cached result of every formula, and LibreOffice trusts cached results in ",[14,2036,2037],{},".xlsx"," files by default, so it would simply show zeros. Saving the file once through openpyxl drops the cached values, which forces LibreOffice to calculate:",[38,2040,2042],{"className":240,"code":2041,"language":242,"meta":43,"style":43},"from openpyxl import load_workbook\n\nload_workbook(\"loan_schedule.xlsx\").save(\"loan_schedule_nocache.xlsx\")\n",[14,2043,2044,2057,2061],{"__ignoreMap":43},[47,2045,2046,2049,2052,2054],{"class":49,"line":50},[47,2047,2048],{"class":253},"from",[47,2050,2051],{"class":249}," openpyxl ",[47,2053,393],{"class":253},[47,2055,2056],{"class":249}," load_workbook\n",[47,2058,2059],{"class":49,"line":271},[47,2060,408],{"emptyLinePlaceholder":407},[47,2062,2063,2066,2068,2071,2074],{"class":49,"line":288},[47,2064,2065],{"class":249},"load_workbook(",[47,2067,1440],{"class":57},[47,2069,2070],{"class":249},").save(",[47,2072,2073],{"class":57},"\"loan_schedule_nocache.xlsx\"",[47,2075,340],{"class":249},[38,2077,2079],{"className":40,"code":2078,"language":42,"meta":43,"style":43},"soffice -env:UserInstallation=file:\u002F\u002F$PWD\u002Flo_profile --headless --convert-to xlsx --outdir recalculated loan_schedule_nocache.xlsx > \u002Fdev\u002Fnull 2>&1\nls recalculated\n",[14,2080,2081,2122],{"__ignoreMap":43},[47,2082,2083,2086,2089,2092,2095,2098,2101,2104,2107,2110,2113,2116,2119],{"class":49,"line":50},[47,2084,2085],{"class":53},"soffice",[47,2087,2088],{"class":257}," -env:UserInstallation=file:\u002F\u002F",[47,2090,2091],{"class":249},"$PWD",[47,2093,2094],{"class":257},"\u002Flo_profile",[47,2096,2097],{"class":257}," --headless",[47,2099,2100],{"class":257}," --convert-to",[47,2102,2103],{"class":57}," xlsx",[47,2105,2106],{"class":257}," --outdir",[47,2108,2109],{"class":57}," recalculated",[47,2111,2112],{"class":57}," loan_schedule_nocache.xlsx",[47,2114,2115],{"class":253}," >",[47,2117,2118],{"class":57}," \u002Fdev\u002Fnull",[47,2120,2121],{"class":253}," 2>&1\n",[47,2123,2124,2127],{"class":49,"line":271},[47,2125,2126],{"class":53},"ls",[47,2128,2129],{"class":57}," recalculated\n",[38,2131,2133],{"className":240,"code":2132,"language":242,"meta":43,"style":43},"ws = load_workbook(\"recalculated\u002Floan_schedule_nocache.xlsx\", data_only=True)[\"Schedule\"]\nsheet = pd.DataFrame(ws.iter_rows(min_row=7, max_row=6 + months, min_col=3, max_col=7, values_only=True),\n                     columns=[\"Opening\", \"Payment\", \"Interest\", \"Principal\", \"Closing\"])\ndiff = (sheet - plan[[\"Opening\", \"Payment\", \"Interest\", \"Principal\", \"Closing\"]]).abs().max().max()\nprint(f\"payment cell {ws['B4'].value}, largest difference from the Python schedule: {diff:.6f}\")\n",[14,2134,2135,2164,2223,2254,2290],{"__ignoreMap":43},[47,2136,2137,2139,2141,2144,2147,2149,2152,2154,2156,2159,2161],{"class":49,"line":50},[47,2138,1447],{"class":249},[47,2140,254],{"class":253},[47,2142,2143],{"class":249}," load_workbook(",[47,2145,2146],{"class":57},"\"recalculated\u002Floan_schedule_nocache.xlsx\"",[47,2148,17],{"class":249},[47,2150,2151],{"class":549},"data_only",[47,2153,254],{"class":253},[47,2155,1475],{"class":257},[47,2157,2158],{"class":249},")[",[47,2160,1455],{"class":57},[47,2162,2163],{"class":249},"]\n",[47,2165,2166,2169,2171,2174,2177,2179,2182,2184,2187,2189,2191,2193,2196,2199,2201,2203,2205,2208,2210,2212,2214,2217,2219,2221],{"class":49,"line":271},[47,2167,2168],{"class":249},"sheet ",[47,2170,254],{"class":253},[47,2172,2173],{"class":249}," pd.DataFrame(ws.iter_rows(",[47,2175,2176],{"class":549},"min_row",[47,2178,254],{"class":253},[47,2180,2181],{"class":257},"7",[47,2183,17],{"class":249},[47,2185,2186],{"class":549},"max_row",[47,2188,254],{"class":253},[47,2190,1937],{"class":257},[47,2192,323],{"class":253},[47,2194,2195],{"class":249}," months, ",[47,2197,2198],{"class":549},"min_col",[47,2200,254],{"class":253},[47,2202,720],{"class":257},[47,2204,17],{"class":249},[47,2206,2207],{"class":549},"max_col",[47,2209,254],{"class":253},[47,2211,2181],{"class":257},[47,2213,17],{"class":249},[47,2215,2216],{"class":549},"values_only",[47,2218,254],{"class":253},[47,2220,1475],{"class":257},[47,2222,562],{"class":249},[47,2224,2225,2228,2230,2233,2235,2237,2239,2241,2243,2245,2247,2249,2251],{"class":49,"line":288},[47,2226,2227],{"class":549},"                     columns",[47,2229,254],{"class":253},[47,2231,2232],{"class":249},"[",[47,2234,1668],{"class":57},[47,2236,17],{"class":249},[47,2238,574],{"class":57},[47,2240,17],{"class":249},[47,2242,991],{"class":57},[47,2244,17],{"class":249},[47,2246,604],{"class":57},[47,2248,17],{"class":249},[47,2250,652],{"class":57},[47,2252,2253],{"class":249},"])\n",[47,2255,2256,2259,2261,2264,2266,2269,2271,2273,2275,2277,2279,2281,2283,2285,2287],{"class":49,"line":343},[47,2257,2258],{"class":249},"diff ",[47,2260,254],{"class":253},[47,2262,2263],{"class":249}," (sheet ",[47,2265,520],{"class":253},[47,2267,2268],{"class":249}," plan[[",[47,2270,1668],{"class":57},[47,2272,17],{"class":249},[47,2274,574],{"class":57},[47,2276,17],{"class":249},[47,2278,991],{"class":57},[47,2280,17],{"class":249},[47,2282,604],{"class":57},[47,2284,17],{"class":249},[47,2286,652],{"class":57},[47,2288,2289],{"class":249},"]]).abs().max().max()\n",[47,2291,2292,2294,2296,2298,2301,2303,2306,2309,2312,2314,2317,2319,2322,2325,2327,2329],{"class":49,"line":449},[47,2293,346],{"class":257},[47,2295,349],{"class":249},[47,2297,352],{"class":253},[47,2299,2300],{"class":57},"\"payment cell ",[47,2302,359],{"class":358},[47,2304,2305],{"class":249},"ws[",[47,2307,2308],{"class":57},"'B4'",[47,2310,2311],{"class":249},"].value",[47,2313,368],{"class":358},[47,2315,2316],{"class":57},", largest difference from the Python schedule: ",[47,2318,359],{"class":358},[47,2320,2321],{"class":249},"diff",[47,2323,2324],{"class":253},":.6f",[47,2326,368],{"class":358},[47,2328,371],{"class":57},[47,2330,340],{"class":249},[10,2332,2333,2334,2338],{},"Every cell matches, so the live workbook and the Python calculation agree. ",[27,2335,2337],{"href":2336},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fstore-formula-results-with-xlsxwriter\u002F","Store Formula Results with xlsxwriter"," shows how to write the Python values as cached results so the file displays numbers even in viewers that do not calculate.",[33,2340,2342],{"id":2341},"common-pitfalls","Common pitfalls",[2344,2345,2346,2354,2360,2369,2375],"ul",{},[2347,2348,2349,2353],"li",{},[2350,2351,2352],"strong",{},"Unrounded interest."," Lenders round interest to cents each period; a schedule that does not will drift by cents from the statement.",[2347,2355,2356,2359],{},[2350,2357,2358],{},"Forgetting the final adjustment."," With a rounded payment, the last payment must clear whatever balance is left, or the schedule ends with a few cents outstanding or a negative balance.",[2347,2361,2362,2365,2366,2368],{},[2350,2363,2364],{},"Excel's sign convention."," ",[14,2367,16],{}," returns a negative payment for a positive loan; negate the present value or the result.",[2347,2370,2371,2374],{},[2350,2372,2373],{},"Annual rate used as monthly."," Divide by the number of payments per year — and check the agreement for how it defines the periodic rate.",[2347,2376,2377,2380,2381,2384],{},[2350,2378,2379],{},"Day-count conventions."," Some loans charge interest on actual days (actual\u002F365); for those, interest is ",[14,2382,2383],{},"balance × rate × days \u002F 365",", and months of different lengths give different interest.",[33,2386,2388],{"id":2387},"conclusion","Conclusion",[10,2390,2391],{},"Compute the payment with the annuity formula and round it to cents, then build the schedule row by row: interest on the opening balance rounded to cents, principal as payment minus interest, and a final payment that clears the balance exactly. Put the totals and payoff date above the table, model overpayments with an extra amount per period, and write a workbook whose schedule is live formulas driven by input cells — checked against the Python schedule by recalculating it once.",[33,2393,2395],{"id":2394},"frequently-asked-questions","Frequently asked questions",[10,2397,2398,2401],{},[2350,2399,2400],{},"How do I calculate a loan payment in Python?","\nUse the annuity formula: payment = principal × r \u002F (1 − (1 + r)^−n), where r is the periodic rate and n the number of payments. It gives the same result as Excel's PMT without the negative sign.",[10,2403,2404,2407],{},[2350,2405,2406],{},"Why doesn't my amortization schedule end at exactly zero?","\nThe payment is rounded to cents, so the schedule accumulates a few cents of difference. Make the final payment equal to the remaining balance plus that month's interest, which clears the loan exactly.",[10,2409,2410,2413],{},[2350,2411,2412],{},"How do extra payments change the schedule?","\nAdd the extra amount to the principal paid each month. The balance falls faster, less interest accrues, and the schedule ends early; stop the loop when the balance reaches zero.",[10,2415,2416,2419],{},[2350,2417,2418],{},"Can the Excel version recalculate when I change the rate?","\nYes, if the schedule is written as formulas that refer to input cells for the amount, rate and term. Changing an input then recalculates every row when Excel opens or edits the file.",[33,2421,2423],{"id":2422},"related","Related",[2344,2425,2426,2432,2439,2446],{},[2347,2427,2428,2429,2431],{},"Up one level: ",[27,2430,30],{"href":29}," — the shared shape of finance reports.",[2347,2433,2434,2438],{},[27,2435,2437],{"href":2436},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fpmt-npv-and-irr-in-python\u002F","PMT, NPV, IRR and XIRR Excel Functions in Python"," — the financial functions behind the schedule.",[2347,2440,2441,2445],{},[27,2442,2444],{"href":2443},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fgoal-seek-in-python-instead-of-excel\u002F","Goal Seek in Python Instead of Excel"," — finding the rate behind a given payment.",[2347,2447,2448,2452],{},[27,2449,2451],{"href":2450},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-fixed-asset-depreciation-schedule\u002F","Build a Fixed Asset Depreciation Schedule"," — the other schedule finance teams maintain.",[2454,2455,2456],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":43,"searchDepth":271,"depth":271,"links":2458},[2459,2460,2461,2462,2463,2464,2465,2466,2467,2468],{"id":35,"depth":271,"text":36},{"id":217,"depth":271,"text":218},{"id":379,"depth":271,"text":380},{"id":946,"depth":271,"text":947},{"id":1127,"depth":271,"text":1128},{"id":1407,"depth":271,"text":1408},{"id":2341,"depth":271,"text":2342},{"id":2387,"depth":271,"text":2388},{"id":2394,"depth":271,"text":2395},{"id":2422,"depth":271,"text":2423},"2026-09-29","Generate a month-by-month repayment schedule: the payment from the annuity formula, interest and principal split per period, cent rounding and a final payment that clears the balance exactly, overpayments that shorten the term, totals and payoff date, and a workbook with live PMT formulas checked against the Python result.","md",[2473,2475,2477,2479],{"q":2400,"a":2474},"Use the annuity formula: payment = principal × r \u002F (1 − (1 + r)^−n), where r is the periodic rate and n the number of payments. It gives the same result as Excel's PMT without the negative sign.",{"q":2406,"a":2476},"The payment is rounded to cents, so the schedule accumulates a few cents of difference. Make the final payment equal to the remaining balance plus that month's interest, which clears the loan exactly.",{"q":2412,"a":2478},"Add the extra amount to the principal paid each month. The balance falls faster, less interest accrues, and the schedule ends early; stop the loop when the balance reaches zero.",{"q":2418,"a":2480},"Yes, if the schedule is written as formulas that refer to input cells for the amount, rate and term. Changing an input then recalculates every row when Excel opens or edits the file.","build a loan amortization schedule in excel",null,{"breadcrumb":2484},[2485,2487,2490],{"name":2486,"item":282},"Home",{"name":2488,"item":2489},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":2491,"item":29},"Building Finance and Operations Reports in Excel with Python","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel",{"title":2494,"description":2495},"Loan Amortization Schedule in Excel with Python and pandas","Build an amortization schedule in Python: PMT formula, interest\u002Fprincipal split, rounding to cents with an adjusted final payment, extra payments, total interest, a balance chart and an xlsxwriter workbook with live formulas verified by LibreOffice.","build-a-loan-amortization-schedule-in-excel","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-amortization-schedule-in-excel\u002Findex",[2499,2500],"report-layouts","formula-equivalents","how-to","mdy1Cxp_bLIyjJE1pXopxKgxn41-29doHvnJlFf7deM",[2504,2508],{"title":2505,"path":2506,"stem":2507,"children":-1},"Build a Lease Liability Schedule","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-lease-liability-schedule","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-lease-liability-schedule\u002Findex",{"title":2509,"path":2510,"stem":2511,"children":-1},"Build a Loan Covenant Compliance Report","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-covenant-compliance-report","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-loan-covenant-compliance-report\u002Findex",[2513,2515],{"path":2514,"title":2491},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports",{"path":2516,"title":2488},"\u002Fautomating-reporting-workflows",[],[2519,2523,2526,2529,2532,2535],{"path":2520,"title":2521,"kind":2522},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards","Building Multi-Sheet Excel Dashboards","guide",{"path":2524,"title":2525,"kind":2501},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-data-dictionary-sheet-to-an-excel-report","Add a Data Dictionary Sheet to an Excel Report",{"path":2527,"title":2528,"kind":2501},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-kpi-summary-block-to-an-excel-dashboard","Add a KPI Summary Block to an Excel Dashboard",{"path":2530,"title":2531,"kind":2501},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-table-of-contents-sheet-with-hyperlinks-in-excel","Add a Table of Contents Sheet with Hyperlinks in Excel",{"path":2533,"title":2534,"kind":2501},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-slicer-style-filters-to-an-excel-dashboard","Add a Dropdown Filter That Drives an Excel Dashboard",{"path":2536,"title":2537,"kind":2501},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-summary-sheet-to-excel-report-python","Add a Summary Sheet to an Excel Report with Python",1790972121646]