Tutorial · Python

Python currency converter with live and historical rates

Get live and historical exchange rates in Python, convert amounts exactly with Decimal, round them to each currency's minor units, and convert a whole pandas DataFrame of orders.

Updated October 29, 2026 · 7 min read

A Python currency converter needs three things: current (or historical) exchange rates from an API, exact arithmetic with decimal.Decimal, and rounding to the target currency’s minor units. This guide builds all three with requests and the currencyapi API: a small fx.py module with error handling and caching, a pandas example for past orders, and a command-line converter. To convert single amounts in one call instead, the currency converter API has a /v3/convert endpoint (from the Small plan).

The code needs Python 3.10 or later. It was tested with Python 3.11, requests 2.34, Babel 2.18 and pandas 3.1 against a local server that answers with the examples from the API documentation.

Before you start

  1. Create a free account and copy your API key.
  2. Keep it in an environment variable, not in your code:
export CURRENCYAPI_KEY="your-api-key"
pip install requests babel pandas
EndpointWhat you getPlans
/v3/latestLatest rates for any base currencyAll
/v3/historicalEnd-of-day rates for up to 31 past dates per call, back to 1999All
/v3/convertConverted amounts, calculated by the APISmall and up

Rates update daily on the Free plan, hourly on Small, every 15 minutes on Pro and every 60 seconds on Medium and up (pricing).

1. Fetch the latest rates

fx.py sends every request through one session with the key in the apikey header, the recommended way to authenticate:

# fx.py
import os
from decimal import Decimal

import requests

BASE_URL = "https://api.currencyapi.com/v3"
session = requests.Session()
session.headers["apikey"] = os.environ["CURRENCYAPI_KEY"]


class CurrencyApiError(Exception):
    def __init__(self, status: int, code: str, message: str, errors: dict | None = None):
        super().__init__(f"currencyapi {status} ({code}): {message}")
        self.status = status
        self.code = code
        self.errors = errors or {}  # 422: which parameter is wrong, and why


def api_get(endpoint: str, **params) -> dict:
    resp = session.get(f"{BASE_URL}/{endpoint}", params=params, timeout=10)
    if not resp.ok:
        try:
            body = resp.json()
        except ValueError:
            body = {}
        code = (body.get("error") or {}).get("code") or (
            "validation_error" if resp.status_code == 422 else "error"
        )
        raise CurrencyApiError(resp.status_code, code, body.get("message", resp.reason), body.get("errors"))
    # parse_float=Decimal keeps every rate exactly as the API wrote it
    return resp.json(parse_float=Decimal)


def to_rates(data: dict, base: str) -> dict[str, Decimal]:
    """{"EUR": {"code": "EUR", "value": 0.918456}} → {"EUR": Decimal("0.918456"), "USD": Decimal(1)}"""
    rates = {code: Decimal(item["value"]) for code, item in data.items()}
    rates[base] = Decimal(1)  # the base itself, also when you filter with currencies
    return rates


def latest_rates(base: str = "USD", currencies: list[str] | None = None) -> tuple[str, dict[str, Decimal]]:
    params = {"base_currency": base}
    if currencies:
        params["currencies"] = ",".join(currencies)
    body = api_get("latest", **params)
    return body["meta"]["last_updated_at"], to_rates(body["data"], base)
>>> from fx import latest_rates
>>> updated_at, rates = latest_rates("USD")
>>> rates["EUR"], updated_at
(Decimal('0.918456'), '2023-06-23T10:15:59Z')

The parse_float=Decimal argument is the important part: the JSON number 0.918456 becomes Decimal('0.918456') directly, without a detour through a binary float.

The errors to expect (all status codes):

StatuscodeMeaning
401missing_api_key, invalid_api_keyNo key sent, or the key is wrong
403plan_upgrade_requiredEndpoint or parameter not in your plan
422validation_error (set by api_get)A parameter is invalid; err.errors names it
429quota_exceeded, rate_limit_exceededMonthly quota used up, or more than 10 requests a minute on Free

Errors and validation failures don’t count against your quota (rate limits).

2. Convert amounts with Decimal

Floats can’t hold most decimal fractions exactly, and the error shows up exactly where money is rounded:

>>> 10 * 1.0005
10.004999999999999
>>> round(10 * 1.0005, 2)   # commercial rounding gives 10.01
10.0
>>> round(2.675, 2)
2.67

convert multiplies and divides Decimal values and rounds once, to the minor units of the target currency:

# fx.py (continued)
from decimal import ROUND_HALF_UP, Decimal

from babel.numbers import get_currency_precision


def convert(amount: str | Decimal, from_: str, to: str, rates: dict[str, Decimal], rounding=ROUND_HALF_UP) -> Decimal:
    """Convert `amount` with rates in units per 1 base unit, rounded to the minor units of `to`."""
    if isinstance(amount, float):
        raise TypeError("pass amounts as str or Decimal, not float")
    value = Decimal(amount) * rates[to] / rates[from_]
    minor_unit = Decimal(1).scaleb(-get_currency_precision(to))  # EUR: 0.01, JPY: 1, KWD: 0.001
    return value.quantize(minor_unit, rounding=rounding)

With USD-based rates you can convert between any two currencies. With the documentation’s example rates (1 USD = 0.918456 EUR = 143.5627 JPY = 0.307512 KWD):

CallResult
convert("199.99", "USD", "EUR", rates)Decimal('183.68')
convert("100", "EUR", "GBP", rates)Decimal('85.59'), a cross rate via USD
convert("184.14", "USD", "JPY", rates)Decimal('26436'), the yen has no decimals
convert("1000", "USD", "KWD", rates)Decimal('307.512'), three decimals
convert("-199.99", "USD", "EUR", rates)Decimal('-183.68'), refunds round symmetrically

The division runs with Decimal’s default 28 significant digits, so the only rounding that affects the result is the final quantize. We compared convert with exact fraction arithmetic on 20,000 random amounts, rates and currency pairs: no differences.

ROUND_HALF_UP rounds ties away from zero, as on most invoices. For banker’s rounding pass rounding=ROUND_HALF_EVEN; then convert("10", "USD", "EUR", {"USD": Decimal(1), "EUR": Decimal("1.0005")}, rounding=ROUND_HALF_EVEN) gives Decimal('10.00').

3. Round and format for each currency

get_currency_precision comes from Babel, which ships the Unicode CLDR currency data:

CurrencyDecimalsformat_currency(…, locale="en_US")
EUR, USD2€183.68
JPY0¥26,436
KWD3KWD307.512

The same package formats the result for your users, with the symbol, separators and spacing of their locale:

>>> from babel.numbers import format_currency
>>> format_currency(Decimal("183.68"), "EUR", locale="de_DE")
'183,68\xa0€'

If you’d rather not add Babel, the API’s /v3/currencies endpoint returns decimal_digits for every currency; fetch it once and keep it.

4. Historical rates for past transactions

Orders, invoices and refunds should be converted at the rate of their date. /v3/historical takes up to 31 dates per call:

# fx.py (continued)
def rates_on(dates: list[str], base: str = "USD") -> dict[str, dict[str, Decimal]]:
    """End-of-day rates for up to 31 dates in one call; each date counts as one request."""
    body = api_get("historical", dates=",".join(dates), base_currency=base)
    return {day: to_rates(data, base) for day, data in body["data"].items()}
>>> rates = rates_on(["2026-08-31", "2026-09-30"], base="EUR")
>>> convert("250.00", "USD", "EUR", rates["2026-09-30"])

Rates are end-of-day values back to 1999. Store the rate and its date next to every converted amount, so the result can be reproduced later; the historical exchange rates API page has more on that.

5. Cache the rates

Latest rates only change as often as your plan updates them, and past rates never change. Cache both:

# fx.py (continued)
import time
from functools import lru_cache

TTL_SECONDS = 3600  # Free plan: daily updates, Small: hourly, Pro: every 15 min, Medium and up: every 60 s
_latest: dict[str, tuple[float, tuple[str, dict[str, Decimal]]]] = {}


def cached_latest(base: str = "USD") -> tuple[str, dict[str, Decimal]]:
    hit = _latest.get(base)
    if hit and time.monotonic() - hit[0] < TTL_SECONDS:
        return hit[1]
    try:
        result = latest_rates(base)
    except (CurrencyApiError, requests.RequestException):
        if hit:
            return hit[1]  # API unreachable or quota used up: keep the last rates
        raise
    _latest[base] = (time.monotonic(), result)
    return result


@lru_cache(maxsize=4096)
def cached_rates_on(day: str, base: str = "USD") -> dict[str, Decimal]:
    return rates_on([day], base)[day]  # past rates never change: cache them forever

This in-process cache suits scripts and single-process apps. With several workers (gunicorn, Celery), keep the rates in Redis or your database instead, so every worker shares one copy.

6. Convert a pandas DataFrame of orders

Given an orders.csv with date,currency,amount, this converts every order to euros at the rate of its day. converters={"amount": Decimal} reads the amounts as exact decimals instead of floats:

# orders.py
from decimal import Decimal

import pandas as pd

from fx import convert, rates_on

orders = pd.read_csv("orders.csv", converters={"amount": Decimal})  # date,currency,amount

days = sorted(orders["date"].unique())
rates = {}
for i in range(0, len(days), 31):  # 31 dates per call
    rates |= rates_on(days[i : i + 31], base="EUR")

orders["amount_eur"] = [
    convert(amount, currency, "EUR", rates[day])
    for day, currency, amount in zip(orders["date"], orders["currency"], orders["amount"])
]
print(orders)
print("Total:", sum(orders["amount_eur"]))  # Decimal, exact to the cent
         date currency  amount amount_eur
0  2026-09-30      USD  120.00     108.59
1  2026-09-30      GBP   89.50     103.02
2  2026-10-01      JPY   15800     102.52
3  2026-10-02      USD    0.10       0.09
Total: 314.22

The amounts in this output come from our test server, not from real market rates. The amount_eur column holds Decimal objects (dtype object); that’s what keeps the total exact. For charts and statistics, where exactness doesn’t matter, convert a copy with .astype(float).

7. A command-line converter

Put together, a converter for the terminal is a dozen lines:

# convert.py — python convert.py 199.99 USD EUR
import argparse

from babel.numbers import format_currency

from fx import cached_latest, convert

parser = argparse.ArgumentParser(description="Convert an amount between currencies.")
parser.add_argument("amount")
parser.add_argument("from_currency", type=str.upper)
parser.add_argument("to_currency", type=str.upper)
parser.add_argument("--locale", default="en_US")
args = parser.parse_args()

updated_at, rates = cached_latest("USD")
result = convert(args.amount, args.from_currency, args.to_currency, rates)
print(format_currency(result, args.to_currency, locale=args.locale), f"(rates of {updated_at})")
$ python convert.py 199.99 usd eur --locale de_DE
183,68 € (rates of 2023-06-23T10:15:59Z)

Next steps

Frequently asked questions

Is there a Python library for currency conversion?

Packages such as forex-python wrap free data sources whose terms and availability change. Calling an exchange rate API with requests takes a few lines and gives you control over update frequency, history and errors. currencyapi also has an official package, currencyapicom on PyPI; it returns rates as floats, so wrap them with Decimal(str(value)) before you calculate.

Should I use float for money in Python?

No. 10 * 1.0005 is 10.004999999999999 as a float, and round() turns it into 10.0 instead of 10.01. Parse the API's JSON with parse_float=Decimal, compute with decimal.Decimal and round once with quantize().

How do I round to the right number of decimals for each currency?

Look the decimals up instead of hard-coding 2: babel.numbers.get_currency_precision('JPY') returns 0, 'EUR' 2 and 'KWD' 3. Then value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP).

Can I convert currencies on the free plan?

Yes, with the latest and historical rates and the conversion code in this guide. The /v3/convert endpoint, which does the multiplication on the server, starts with the Small plan. The Free plan has 300 requests a month and is meant for testing and personal projects.

How do I convert a column of past transactions in pandas?

Collect the distinct dates, fetch their rates with /v3/historical?dates=… (up to 31 dates per call, each date counts as one request) and convert row by row with Decimal. The pandas section of this guide shows the full code.

Exchange rate API

Get these rates in your app

currencyapi delivers real-time and historical exchange rates for 170+ currencies, crypto and metals as JSON, updated as often as every 60 seconds.

Read the documentation

300 free requests every month. No credit card required.

GET https://api.currencyapi.com/v3/latest?base_currency=USD&currencies=EUR,GBP,JPY

{
  "meta": { "last_updated_at": "2026-09-30T23:59:59Z" },
  "data": {
    "EUR": { "code": "EUR", "value": 0.8828 },
    "GBP": { "code": "GBP", "value": 0.7556 },
    "JPY": { "code": "JPY", "value": 157.98 }
  }
}

Keep reading

Get your free currency API key

Start with 300 free requests a month