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
- Create a free account and copy your API key.
- Keep it in an environment variable, not in your code:
export CURRENCYAPI_KEY="your-api-key"
pip install requests babel pandas
| Endpoint | What you get | Plans |
|---|---|---|
/v3/latest | Latest rates for any base currency | All |
/v3/historical | End-of-day rates for up to 31 past dates per call, back to 1999 | All |
/v3/convert | Converted amounts, calculated by the API | Small 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):
| Status | code | Meaning |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | No key sent, or the key is wrong |
| 403 | plan_upgrade_required | Endpoint or parameter not in your plan |
| 422 | validation_error (set by api_get) | A parameter is invalid; err.errors names it |
| 429 | quota_exceeded, rate_limit_exceeded | Monthly 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):
| Call | Result |
|---|---|
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:
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
- Want the API to do the multiplication?
/v3/convertreturns converted amounts on the Small plan and up. - Need rates in a spreadsheet instead? See currency data in Excel or the bulk currency converter.
- Working in another language? Read the JavaScript or PHP guide, or the full API documentation.