← blog  /  scrapers

Free exchange rate API with no key: ECB rates in Python

2026-10-28  ·  6 min read  ·  scrapers

The European Central Bank publishes its daily euro rates as plain files: no key, no sign-up. What is in them, the three things that trip people up (weekends, holidays, retired currencies) and about forty lines of Python that handle all three.

Many currency APIs want an account, a key and a monthly request limit. The European Central Bank asks for none of that. Every working day it fixes reference rates for the euro and puts them in three plain files anyone can download.

We read all three on Sunday 25 October 2026. Every number below comes from those files on that day.

The three files#

ECB reference rates: no key, no sign-uptext
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml     the last fixing
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml  about the last 90 days
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.zip      all since 1999, CSV

On our Sunday the daily file held the fixing of Friday 23 October: 29 currencies, each as the price of one euro. One euro was 1.1193 US dollars, 175.51 yen, 0.83728 pounds. The full history held 7,121 fixings, from 4 January 1999 to that Friday, in about 640 KB zipped.

  • Base: always the euro. Every rate says "1 EUR = x".
  • Currencies: 29 today, 41 since 1999.
  • When: usually around 16:00 Frankfurt time, on working days.
  • Digits: as printed. 20 of the 29 have 5 significant digits, the rest 4 to 7.
  • Use: for information. The ECB discourages using them for transactions.

That last point is the ECB's own wording, and it is the honest limit of this source. A reference rate is one fixing a day, from a daily concertation between central banks around 14:10 Frankfurt time. It is not the rate your bank or card will give you at 11 at night. For invoices, reports, accounting and "what was it worth on that day" it is a solid source you can name. For trading it is the wrong tool.

Three things that trip people up#

Weekends. There is no fixing on Saturday or Sunday, so a request for Sunday's rate has to mean Friday's. The daily file on our Sunday still said 23 October, and it keeps saying so until Monday's fixing comes out.

Holidays. The ECB fixes no rates on the closing days of TARGET, the euro payment system. In 2025 those weekdays were 1 January, Good Friday (18 April), Easter Monday (21 April), 1 May, 25 and 26 December. In 2026 so far: 1 January, 3 and 6 April, 1 May. Christmas is the long one: in 2025 the rates of 24 December stood for four days, from the 25th to the 28th.

Currencies come and go. 41 currencies have been in the history and 12 are gone. When a country joins the euro, its currency leaves the file: the Croatian kuna's last fixing was 30 December 2022, the Bulgarian lev's 31 December 2025. The Russian rouble's last fixing was 1 March 2022. In the CSV a currency that is not quoted on a day says N/A, so code that reads every column as a number breaks on the first old row.

One more small one: the CSV's header ends with a comma. That gives an extra column with an empty name, which you skip.

The code#

Standard library only. Decimal keeps the published digits exactly as printed, and every pair goes through the euro, because the euro is the only base the ECB publishes.

ECB rates: latest, history, any pair, any daypython
import csv, io, urllib.request, zipfile
import xml.etree.ElementTree as ET
from datetime import date, timedelta
from decimal import Decimal

ECB = "https://www.ecb.europa.eu/stats/eurofxref/"
HEADERS = {"User-Agent": "rates-example/1.0 (you@example.com)"}

def get(name):
    req = urllib.request.Request(ECB + name, headers=HEADERS)
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()

def latest():
    """The last fixing: ('2026-10-23', {'EUR': 1, 'USD': Decimal('1.1193'), ...})."""
    day = ET.fromstring(get("eurofxref-daily.xml")).find(".//{*}Cube[@time]")
    rates = {c.get("currency"): Decimal(c.get("rate")) for c in day}
    return day.get("time"), {"EUR": Decimal(1), **rates}

def history():
    """Every fixing since 1999 as {date: {currency: rate}}, from the zipped CSV."""
    with zipfile.ZipFile(io.BytesIO(get("eurofxref-hist.zip"))) as z:
        text = z.read(z.namelist()[0]).decode("utf-8")
    out = {}
    for row in csv.DictReader(io.StringIO(text)):
        day = row.pop("Date")
        rates = {c: Decimal(v) for c, v in row.items() if c and v.strip() not in ("", "N/A")}
        out[day] = {"EUR": Decimal(1), **rates}
    return out

def rates_on(day, hist):
    """The fixing in force on a day: weekends and holidays take the last fixing before them."""
    d = date.fromisoformat(day)
    for _ in range(10):
        if d.isoformat() in hist:
            return d.isoformat(), hist[d.isoformat()]
        d -= timedelta(days=1)
    raise LookupError(f"no fixing in the 10 days up to {day}")

def convert(amount, frm, to, rates):
    """Every rate is per 1 euro, so any pair goes through the euro."""
    return Decimal(str(amount)) / rates[frm] * rates[to]
Run itpython
day, now = latest()
print(day, len(now) - 1, "currencies")
print("100 GBP =", round(convert(100, "GBP", "JPY", now), 2), "JPY")

time.sleep(5)                       # the ECB's robots.txt asks for 5 seconds between requests
hist = history()
print(len(hist), "fixings since", min(hist))
print("rates for 2025-12-26 come from", rates_on("2025-12-26", hist)[0])

On 25 October 2026 that printed:

Outputtext
2026-10-23 29 currencies
100 GBP = 20961.92 JPY
7121 fixings since 1999-01-04
rates for 2025-12-26 come from 2025-12-24

The pound-to-yen number is our arithmetic through the euro, not a rate the ECB publishes. It is the usual way to get a cross rate from this source: exactly what the ECB's two numbers for that day imply.

Be a polite reader#

The ECB's robots.txt asks for 5 seconds between requests, and the rates change once a day. So fetch the daily file once after 16:00 Frankfurt time (it was last changed at 15:56 on our Friday), keep a copy, and download the full history only when you need a date older than 90 days. Fetch at nine in the morning and you get the previous working day's fixing, which is right: it is the latest one.

When you would rather not keep this code#

If you need this inside a spreadsheet flow, a no-code tool or an agent, our Currency Exchange Rates (ECB) actor on Apify does the same job from the same files. It has three operations:

  • latest: today's rates against any base, for example the dollar instead of the euro, for all currencies or the ones you list.
  • convert: an amount from one currency to another, on the latest day or any date since 1999. A weekend or holiday uses the fixing before it and says which day it used.
  • history: one pair, day by day, for up to 365 days.

One run takes up to 1,000 of those requests at once, so a whole column of invoice dates becomes one call. The rows export as JSON, CSV or Excel. You can start it from the API, Make, Zapier, n8n, or an agent over MCP. It needs no key of your own beyond an Apify account and is billed per result.

written