Python Exchange Rate API: Currency Conversion and History
Fetch exchange rates in Python, convert currencies by date and load a pandas time series. Includes API key setup, caching and missing-rate handling.
Updated
Use Python's requests library to fetch a rate, then multiply the source amount by the target rate. This guide uses the FxFeed API for latest rates, historical dates, currency conversion and a pandas time series. The printed rate values are examples recorded on September 22, 2026; current responses will differ.
What you need
- Python 3.9 or newer
pip install requests pandas(pandas only for the last section)- An FxFeed API key. Get a free API key: the free plan includes 1,000 requests a month with daily data, and no credit card.
Keep the key out of your code. Put it in an environment variable and read it from there:
export FXFEED_API_KEY="fxf_your_key_here"
Get exchange rates in Python
The API lives at https://api.fxfeed.io/v2. The /latest endpoint answers the current rates for a base currency; currencies narrows the answer to the codes you need.
import os
import requests
API_KEY = os.environ["FXFEED_API_KEY"]
BASE_URL = "https://api.fxfeed.io/v2"
response = requests.get(
f"{BASE_URL}/latest",
params={"base": "USD", "currencies": "EUR,GBP,JPY"},
headers={"X-API-Key": API_KEY},
timeout=10,
)
response.raise_for_status()
data = response.json()
print(data["date"]) # 2026-09-22T00:00:00Z
print(data["rates"]) # {'EUR': 0.87188334, 'GBP': 0.74757604, 'JPY': 157.46992597}
A trimmed response looks like this:
{
"success": true,
"base": "USD",
"date": "2026-09-22T00:00:00Z",
"timestamp": 1790035200,
"rates": { "EUR": 0.87188334, "GBP": 0.74757604, "JPY": 157.46992597 }
}
Each rate says how many units of that currency one unit of the base buys: 1 USD bought 0.8719 EUR. Leave out currencies and you get every currency the API quotes (160+) in the same single request, which matters once you start caching.
On the free plan /latest answers the most recent daily rates; paid plans get hourly data. The date field tells you which rates you have, so show it next to any converted price.
A small client with real error handling
raise_for_status() is fine for a script, but an application should know why a request failed. The API answers failures with an HTTP status and a JSON body carrying a message:
| Status | Meaning | What to do |
|---|---|---|
| 400 | Invalid parameter, e.g. an unknown currency | Fix the request; the message says what is wrong |
| 401 | Missing or unknown API key | Check the key |
| 402 | Monthly request limit reached | Upgrade (the body carries an upgrade_url) or wait for the next month |
| 429 | Too many requests in a short time | Back off and retry |
This helper sends the key in the X-API-Key header instead of the query string, so it never ends up in URLs you log, retries a 429 with backoff, and turns every other failure into one exception type:
import time
import requests
class FxFeedError(Exception):
"""A request the FxFeed API refused, with its HTTP status."""
def __init__(self, status, message, upgrade_url=None):
super().__init__(f"{status}: {message}")
self.status = status
self.upgrade_url = upgrade_url
session = requests.Session()
session.headers["X-API-Key"] = API_KEY # keeps the key out of URLs and logs
def fxfeed_get(path, params, retries=3):
"""GET an FxFeed endpoint and return its JSON, or raise FxFeedError."""
for attempt in range(retries):
response = session.get(f"{BASE_URL}{path}", params=params, timeout=10)
if response.status_code != 429:
break
time.sleep(2 ** attempt) # rate limited: wait 1s, then 2s
try:
body = response.json()
except ValueError:
body = {}
if response.ok:
return body
message = body.get("message", response.reason)
raise FxFeedError(response.status_code, message, body.get("upgrade_url"))
Using it:
try:
fxfeed_get("/latest", {"base": "XXX"})
except FxFeedError as err:
print(err) # 400: invalid base currency: XXX
if err.status == 402:
print("Monthly limit reached, upgrade at", err.upgrade_url)
A 402 is worth handling separately: the request limit resets at the start of the next month (UTC), so a background job can stop and report instead of retrying all day.
Cache rates instead of asking every time
Exchange rates do not change between two requests a second apart, and every request counts towards your monthly allowance. Two rules cover most applications:
- Latest rates: fetch every currency for a base once, and keep the answer for an hour (or a day on the free plan's daily data).
- Historical rates: cache for longer, but allow refreshes for source corrections and backfills. Store the exact rate and observation date used for a completed calculation separately.
from functools import lru_cache
LATEST_TTL = 60 * 60 # seconds
_latest = {}
def latest_rates(base="USD"):
"""Every latest rate for base, fetched at most once an hour."""
hit = _latest.get(base)
if hit and time.monotonic() - hit[0] < LATEST_TTL:
return hit[1]
rates = fxfeed_get("/latest", {"base": base})["rates"]
_latest[base] = (time.monotonic(), rates)
return rates
@lru_cache(maxsize=1024)
def historical_rates(day, base="USD"):
"""Cache historical responses for this process; clear when refreshing data."""
return fxfeed_get("/historical", {"base": base, "date": day})["rates"]
print(latest_rates("USD")["EUR"])
print(latest_rates("USD")["GBP"]) # from the cache: no second request
print(historical_rates("2024-01-02", "EUR")["USD"]) # 1.095601
Because latest_rates asks for every currency at once, requests sharing a cached base reuse the same response. In a web application with several workers, use a shared cache and coordinate concurrent misses. Clear the example's historical cache with historical_rates.cache_clear() when you need a refresh; for a long-running service, use a bounded expiry.
Historical exchange rates and missing dates
The /historical endpoint takes a date in YYYY-MM-DD form and returns "historical": true. Check coverage for both currencies: some history reaches back to 1999, but many currencies begin later. Coverage endpoints describe the available span, not a guarantee that every day inside it has a rate.
An exact date can have an empty rates object, including on weekends or holidays. If your workflow permits the previous available rate, query the preceding week and keep the actual date alongside the result. This example explicitly fails when the pair has no observation in that window:
from datetime import date, timedelta
def rate_on_or_before(day, base, currency):
"""The rate on day, or on the last working day before it."""
start = (date.fromisoformat(day) - timedelta(days=7)).isoformat()
series = fxfeed_get(
"/timeseries",
{"base": base, "currencies": currency, "start_date": start, "end_date": day},
)["rates"]
eligible = [
d for d, values in series.items()
if start <= d <= day and values.get(currency, 0) > 0
]
if not eligible:
raise ValueError(f"No {base}/{currency} rate from {start} through {day}")
last = max(eligible)
return last, series[last][currency]
print(historical_rates("2024-01-06", "USD")) # {} (a Saturday)
print(rate_on_or_before("2024-01-06", "USD", "EUR")) # ('2024-01-05', 0.915667)
Convert currency with the Python API
The API has a /convert endpoint that does the multiplication for you, optionally on a past date:
result = fxfeed_get(
"/convert", {"from": "USD", "to": "EUR", "amount": 100, "date": "2024-01-02"}
)
print(result["result"]) # 91.2741
print(result["info"]["rate"]) # 0.912741
That is convenient for one-off conversions, but each call is a request. When you convert many amounts, convert locally with a cached rate, and use Decimal so money does not pick up floating-point noise:
from decimal import ROUND_HALF_UP, Decimal
def convert(amount, from_currency, to_currency, day=None, decimals=2):
"""Convert with a cached rate, keeping money in Decimal."""
if day:
rates = historical_rates(day, from_currency)
else:
rates = latest_rates(from_currency)
if rates.get(to_currency, 0) <= 0:
raise ValueError(f"No {from_currency}/{to_currency} rate for {day or 'latest'}")
rate = Decimal(str(rates[to_currency]))
quantum = Decimal(1).scaleb(-decimals)
return (Decimal(amount) * rate).quantize(quantum, rounding=ROUND_HALF_UP)
print(convert("100.00", "EUR", "USD", "2024-01-02")) # 109.56
Pass the amount as a decimal string and round only at the end. The example defaults to two decimals for EUR and USD; use decimals=0 for JPY or the precision supplied by the currency metadata. If you show a converted price to a customer, retain and display the observation date too.
Load an exchange-rate time series into pandas
For charts, reports or a quick volatility check, /timeseries returns every day between start_date and end_date in one request, keyed by date. That shape drops straight into a DataFrame:
import pandas as pd
data = fxfeed_get(
"/timeseries",
{
"base": "USD",
"currencies": "EUR,GBP",
"start_date": "2024-01-01",
"end_date": "2024-03-31",
},
)
df = pd.DataFrame.from_dict(data["rates"], orient="index")
df.index = pd.to_datetime(df.index)
df = df.sort_index()
print(df.head(3))
monthly = df.resample("MS").mean() # average rate per calendar month
moves = df.pct_change().dropna() # day-to-day changes
print(monthly.round(4))
print(moves["EUR"].std()) # daily volatility of USD/EUR
The output starts like this:
EUR GBP
2024-01-02 0.912741 0.790845
2024-01-03 0.915834 0.791922
2024-01-04 0.912991 0.787711
Note that 1 January is missing. resample("MS").mean() averages the available observations, not every calendar day, and a partial month remains partial. Inspect df.resample("MS").count() alongside the mean. If your workflow needs a carried-forward calendar-day series, make that policy explicit before using df.asfreq("D").ffill(). Neither calculation is an official tax-authority average.
From there it is ordinary pandas: df.plot() for a chart, df["EUR"].rolling(20).mean() for a moving average, or a join against your own transactions on the date column to value each one at its day's rate.
Production checklist
- Keep the key in the environment or a secret manager, never in the repository, and never in code that runs in a browser.
- Set a timeout on every request (
timeout=10above) so a network problem cannot hang a worker. - Cache: reuse rates by base and date, with expiry and a refresh path for historical corrections.
- Handle 402 and 429 differently: 429 means "slow down", 402 means "no more requests this month".
- Store the date of the rate you used alongside any converted amount, so you can explain a figure later.
- Use
Decimalfor money and round once, at the end.
Where to go next
- Need one manual calculation? Use the historical currency converter by date and save the rate date with the result.
- Check the latest rates endpoint or the time series endpoint for every parameter and response field.
- The currency list shows every code the API quotes, and how far back each one goes.
- Building a web front end? The JavaScript guide shows how to keep the key on the server.
- Comparing providers? See how FxFeed compares with Open Exchange Rates, Fixer and ExchangeRate-API.
Ready to try it with your own data? Get a free API key and run the first snippet: it takes about a minute.
Ready to integrate FX rates?
Start using FxFeed.io today with our free tier. No credit card required.