How to Get Exchange Rates in Python
Fetch latest and historical exchange rates in Python with requests: error handling, retries, caching, currency conversion with Decimal, and a pandas time series, all against a real FX API.
Most Python projects that touch money in more than one currency end up needing the same few things: today's rate, the rate on a past date, a conversion, and now and then a range of rates to analyse. This guide builds all of them with requests and the FxFeed API, step by step, ending with a pandas time series. Every snippet below was run against the live API; the printed values are what it returned.
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"
Your first request
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", "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: a past day's rate never changes, so cache it for as long as your process lives, or store it in your database.
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"):
"""Every rate for base on a past day (YYYY-MM-DD). Past days never change."""
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, converting to EUR, GBP and JPY costs one request an hour rather than one per conversion. In a web application with several worker processes, put the same idea in Redis or your database instead of a module-level dict.
Historical rates
The /historical endpoint takes a date in YYYY-MM-DD form and answers the rates for that day, with "historical": true in the response. History goes back to 1999, which covers most accounting and reporting needs: invoices, expense reports, tax records and backtests all want the rate on the day of the transaction, not today's.
Rates are published on working days. A Saturday, a Sunday or a bank holiday has no rates of its own, and /historical answers an empty rates object for it. Most accounting practice uses the last working day's rate for those dates, and one /timeseries request over the week before finds it:
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"]
last = max(d for d, rates in series.items() if currency in rates)
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)
Converting amounts
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):
"""Convert with a cached rate, keeping money in Decimal."""
if day:
rates = historical_rates(day, from_currency)
else:
rates = latest_rates(from_currency)
rate = Decimal(str(rates[to_currency]))
return (Decimal(amount) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(convert("100.00", "EUR", "USD", "2024-01-02")) # 109.56
Round only at the end, and to the number of decimals the target currency uses (two for EUR and USD, zero for JPY). If you show a converted price to a customer, show the rate's date with it.
Time series with 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: the series contains the days a rate was published. If you need a value for every calendar day, forward-fill: df.asfreq("D").ffill().
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: one request per base currency per hour for latest rates, and forever for past days.
- 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
- The API reference lists every parameter and response field, with examples in other languages.
- 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.