How-to
Advanced Data Transformation And CleaningDeep dive

FV, PV, RATE and NPER Equivalents in Python

Reproduce Excel's time-value-of-money functions in Python: FV for a savings plan, PV for what a payment stream is worth, NPER for how long a loan takes, and RATE for the interest rate implied by a deal — with Excel's sign convention, the type argument for payments at the start of a period, zero-rate cases, vectorised versions for whole DataFrames, and a LibreOffice check that the numbers match.

Four spreadsheet functions answer most everyday finance questions. FV: how much will I have if I save 200 a month for ten years? PV: what lump sum is a stream of payments worth today? NPER: how many months to pay off a loan at a given instalment? RATE: what interest rate does a "pay 500 a month for four years on a 20,000 loan" offer actually imply? They share one equation and one sign convention, and once that is clear, each is a few lines of Python — useful when a report outgrows the spreadsheet but the numbers must still match it exactly. This page builds the four functions, checks them against the spreadsheet, and applies them to whole DataFrame columns. It belongs to Excel Formula Equivalents in pandas.

Prerequisites

Bash
pip install numpy pandas scipy openpyxl

LibreOffice (soffice) recalculates the reference formulas in Step 6. The numpy-financial package implements the same functions; writing them out shows exactly what they assume, and avoids a dependency for four short formulas.

Step 1 — The equation and the sign convention

All four functions solve one equation that balances the present value pv, a regular payment pmt over nper periods at rate, and a future value fv:

pv·(1+rate)^nper + pmt·(1 + rate·type)·((1+rate)^nper − 1)/rate + fv = 0

The equation equals zero because cash paid out and cash received have opposite signs. Saving 200 a month means pmt = -200 (money leaves your account) and the resulting balance is positive (money you get back). Borrowing 20,000 means pv = +20000 (received) and the instalments are negative. type is 0 when payments happen at the end of each period, 1 when they happen at the start. Getting the signs wrong is the most common reason a Python version "disagrees" with Excel.

Cash-flow signs in the time-value functions Money you pay out is negative and money you receive is positive. In a savings plan the deposits are negative and the future value positive. In a loan the amount borrowed is positive and the instalments negative. Savings plan (FV) pv = −1,000 initial deposit pmt = −200 per month result fv = +32,703 Loan (NPER, RATE) pv = +20,000 borrowed pmt = −500 per month fv = 0 when repaid

Step 2 — FV and PV

Python
import math

def fv(rate, nper, pmt, pv=0.0, type=0):
    if rate == 0:
        return -(pv + pmt * nper)
    growth = (1 + rate) ** nper
    return -(pv * growth + pmt * (1 + rate * type) * (growth - 1) / rate)

def pv(rate, nper, pmt, fv=0.0, type=0):
    if rate == 0:
        return -(fv + pmt * nper)
    growth = (1 + rate) ** nper
    return -(fv + pmt * (1 + rate * type) * (growth - 1) / rate) / growth

print(f"Save 200/month for 10 years at 5%, starting with 1,000: {fv(0.05/12, 120, -200, -1000):,.2f}")
print(f"  ...paying at the start of each month instead:        {fv(0.05/12, 120, -200, -1000, type=1):,.2f}")
print(f"Lump sum worth 450/month for 5 years at 6%:            {pv(0.06/12, 60, -450):,.2f}")
print(f"No interest, ten payments of 100:                       {fv(0, 10, -100):,.2f}")

The savings plan reaches 32,703.47; paying at the start of each month earns one extra month of interest on every deposit and gives 32,832.87. The payment stream of 450 a month for five years is worth 23,276.50 today at 6%. Monthly rates and periods are the annual figures divided and multiplied by 12 — the same convention Excel users follow. The rate == 0 branch matters: the general formula divides by the rate, and a zero-interest promotion would otherwise raise ZeroDivisionError.

Step 3 — NPER: how many periods

Solving the equation for nper gives a logarithm:

Python
def nper(rate, pmt, pv, fv=0.0, type=0):
    if rate == 0:
        return -(fv + pv) / pmt
    z = pmt * (1 + rate * type) / rate
    ratio = (-fv + z) / (pv + z)
    if ratio <= 0:
        raise ValueError("payment never repays the balance (Excel returns #NUM!)")
    return math.log(ratio) / math.log(1 + rate)

months = nper(0.07/12, -500, 20000)
print(f"20,000 at 7% with 500/month: {months:.2f} months ({math.ceil(months)} payments)")
try:
    nper(0.07/12, -100, 20000)
except ValueError as exc:
    print("100/month:", exc)

The loan takes 45.68 months — 46 payments, the last one smaller. With only 100 a month, the payment does not even cover the interest (20,000 × 7% / 12 ≈ 117), the balance grows for ever, and the logarithm has no real answer; Excel shows #NUM!, and the function raises a clear error instead of returning nan.

Loan balance over time at two instalments The balance of a 20,000 loan at 7 percent. With 500 a month it falls to zero after about 46 months. With 100 a month, which is less than the monthly interest, it keeps growing. 500 / month 100 / month 0 10k 20k 30k 0 12 24 36 46

Step 4 — RATE: the implied interest rate

RATE has no closed form; Excel searches for it. A bracketing root-finder on the same equation does the job reliably:

Python
from scipy.optimize import brentq

def balance_equation(r, nper_, pmt, pv_, fv_, type_):
    if abs(r) < 1e-12:
        return pv_ + pmt * nper_ + fv_
    growth = (1 + r) ** nper_
    return pv_ * growth + pmt * (1 + r * type_) * (growth - 1) / r + fv_

def rate(nper_, pmt, pv_, fv_=0.0, type_=0, low=-0.99, high=1.0):
    return brentq(balance_equation, low, high, args=(nper_, pmt, pv_, fv_, type_), xtol=1e-14)

monthly = rate(48, -500, 20000)
print(f"20,000 repaid at 500/month for 48 months: {monthly:.6%} per month, {monthly * 12:.4%} per year")
print(f"  ...with payments in advance (type=1):    {rate(48, -500, 20000, type_=1) * 12:.4%} per year")

The offer implies 0.770% a month, 9.24% a year (nominal, monthly compounding); if the first instalment is due on signing, the implied rate rises to 9.66%, because the borrower effectively receives less money for the same payments. The bracket from −99% to +100% per period covers any realistic deal. Comparing offers by their implied rate rather than their headline monthly payment is a common use — see PMT, NPV and IRR in Python for the payment side.

Step 5 — Whole columns at once

Reports apply these functions to a table of accounts or offers. NumPy versions of FV and PV vectorise directly; np.where handles the zero-rate rows:

Python
import numpy as np
import pandas as pd

plans = pd.DataFrame({
    "Plan": ["Basic", "Plus", "Promo", "Long"],
    "AnnualRate": [0.03, 0.05, 0.0, 0.045],
    "Years": [5, 10, 2, 25],
    "Monthly": [100, 200, 300, 150],
})
r = plans["AnnualRate"].to_numpy() / 12
n = plans["Years"].to_numpy() * 12
p = -plans["Monthly"].to_numpy()
with np.errstate(divide="ignore", invalid="ignore"):
    growth = (1 + r) ** n
    fv_vec = np.where(r == 0, -(p * n), -(p * (growth - 1) / np.where(r == 0, 1, r)))
plans["FutureValue"] = fv_vec.round(2)
plans["Deposited"] = plans["Monthly"] * n
plans["Interest"] = plans["FutureValue"] - plans["Deposited"]
print(plans.to_string(index=False))

np.errstate silences the division warning for the zero-rate row, whose result comes from the other branch of np.where. The interest column makes the effect of time obvious: the 25-year plan earns 37,950 in interest on 45,000 of deposits, while the 5-year plan earns under 500. For RATE and NPER on a column, apply the scalar functions row by row with a list comprehension — they are cheap, and keeping the error handling per row is worth more than vectorising.

Step 6 — Check against the spreadsheet

Python
import subprocess
from openpyxl import Workbook, load_workbook

cases = {
    "=FV(0.05/12,120,-200,-1000,0)": fv(0.05/12, 120, -200, -1000),
    "=FV(0.05/12,120,-200,-1000,1)": fv(0.05/12, 120, -200, -1000, 1),
    "=PV(0.06/12,60,-450,5000,0)": pv(0.06/12, 60, -450, 5000),
    "=NPER(0.07/12,-500,20000)": nper(0.07/12, -500, 20000),
    "=RATE(48,-500,20000)": rate(48, -500, 20000),
}
wb = Workbook()
for i, formula in enumerate(cases, start=1):
    wb.active[f"A{i}"] = formula
wb.save("tvm.xlsx")
subprocess.run(["soffice", "--headless", "--convert-to", "xlsx", "--outdir", "recalc", "tvm.xlsx"],
               check=True, capture_output=True)
ws = load_workbook("recalc/tvm.xlsx", data_only=True).active
for i, (formula, mine) in enumerate(cases.items(), start=1):
    print(f"{formula:32} sheet {ws[f'A{i}'].value:>14.6f}  python {mine:>14.6f}")

Every pair agrees to six decimal places, including the PV with a non-zero future value (a balloon payment of 5,000 at the end). Keeping this comparison as a test means a future edit to the functions cannot drift away from the spreadsheet unnoticed.

Which function answers which question FV gives the balance after saving or the remaining balance of a loan. PV gives today's value of a payment stream or the loan a payment affords. NPER gives the number of payments needed. RATE gives the interest rate a deal implies. solves for typical question FV future value What will my savings be worth? PV present value How big a loan can I afford? NPER number of periods How long until it is repaid? RATE interest per period What rate does this offer imply?

Common pitfalls

  • Signs. Payments out are negative, money in is positive; mixing them flips or breaks results.
  • Annual versus monthly. Divide annual rates by 12 and multiply years by 12 for monthly payments — and convert RATE's monthly answer back.
  • Zero rates. The general formulas divide by the rate; handle rate == 0 separately.
  • Impossible schedules. A payment smaller than the interest has no NPER; raise instead of returning nan.
  • Nominal versus effective rates. RATE × 12 is the nominal annual rate; the effective rate is (1 + rate)**12 - 1.

Conclusion

FV, PV, NPER and RATE are four views of one equation linking a present value, a regular payment, a number of periods, a rate and a future value, with money out negative and money in positive. Write FV and PV directly, NPER with a logarithm, and RATE with brentq; handle zero rates, the start-of-period type, and schedules that never repay; vectorise with NumPy for whole columns; and confirm against the spreadsheet's own formulas with a LibreOffice recalculation.

Frequently asked questions

How do I calculate Excel's FV in Python? Use fv = -(pv*(1+rate)nper + pmt(1+ratetype)((1+rate)**nper - 1)/rate), or -(pv + pmtnper) when the rate is zero. Pass payments as negative numbers, as Excel does.

Why does my Python NPER or RATE give a different answer from Excel? Usually the signs or periods differ. Excel expects money paid out as negative and money received as positive, and rates and periods in the same unit, for example a monthly rate with a number of months.

How do I calculate RATE in Python without numpy-financial? Solve the time-value equation for the rate numerically, for example with scipy.optimize.brentq between -0.99 and 1.0 per period, then multiply a monthly result by 12 for the nominal annual rate.

What does the type argument do in FV and PV? type=0 means payments at the end of each period and type=1 at the start. Payments at the start earn or cost one extra period of interest, so the results are slightly larger.

More on excel functions in pandas

More on excel functions in pandas →