Skip to content
Back to Blog
8 min read

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:

  1. 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).
  2. 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=10 above) 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 Decimal for 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.

Share this article:
All Articles

Ready to integrate FX rates?

Start using FxFeed.io today with our free tier. No credit card required.