How to get NUTS-3 GDP per capita out of Eurostat
Eurostat publishes GDP per inhabitant for every European district-level region (NUTS-3) — free, official, authoritative. It is also famously hard to navigate the first time. This is the shortest honest path, both ways we actually use in production, plus the traps we hit ourselves while loading this exact dataset for 47-country coverage.
The dataset you want
nama_10r_3gdp — "Gross domestic product (GDP) at current market prices by NUTS 3 region". Unit EUR_HAB = euro per inhabitant. That is the number behind every "GDP per capita by region" map you have seen.
Path A — the databrowser (no code)
- Open the dataset in Eurostat's databrowser: ec.europa.eu/eurostat/databrowser/view/nama_10r_3gdp.
- In the Unit of measure dimension pick Euro per inhabitant (the default view is millions of euro — a classic first-time confusion).
- In the Geopolitical entity dimension the tree mixes all NUTS levels. NUTS-3 regions are the ones with 5-character codes (e.g.
DE300Berlin,FR101Paris). Country rows (DE) and NUTS-2 rows (DE30) sit in the same list — filter by level or you will average apples with orchards. - Pick your years, then Download → Full dataset / Selected view as CSV or Excel.
Path B — the JSON API (reproducible)
One GET request, no key needed. Worked example — Berlin, 2020 onward:
https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nama_10r_3gdp
?format=JSON&lang=EN&unit=EUR_HAB&geo=DE300&sinceTimePeriod=2020
Run it today and you get exactly: 2020 = 44,700 · 2021 = 48,200 · 2022 = 51,700 · 2023 = 54,700 euro per inhabitant — and nothing for 2024. That empty slot is not a bug; it is trap #1 below. The response is JSON-stat: values sit in a flat value dict keyed by a computed index. Minimal Python to decode a multi-region pull (this is a trimmed version of the code we run monthly):
import json, urllib.request
url = ("https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/"
"data/nama_10r_3gdp?format=JSON&lang=EN&unit=EUR_HAB&sinceTimePeriod=2015")
js = json.loads(urllib.request.urlopen(url, timeout=180).read())
ids, size = js["id"], js["size"]
stride = [1] * len(size)
for i in range(len(size) - 2, -1, -1):
stride[i] = stride[i + 1] * size[i + 1]
gi, ti = ids.index("geo"), ids.index("time")
geo = js["dimension"]["geo"]["category"]["index"]
time = js["dimension"]["time"]["category"]["index"]
for code, gp in geo.items():
if len(code) != 5: # NUTS-3 only — skip country and NUTS-2 rows
continue
for year, tp in time.items():
v = js["value"].get(str(gp * stride[gi] + tp * stride[ti]))
if v is not None:
print(code, year, v)
The five traps (we hit every one of them)
- GDP lags ~2 years. The freshest NUTS-3 GDP year is typically two calendar years behind (2023 as of mid-2026); demography updates faster. If a page claims "2024 district GDP", ask which dataset.
- Greece is
EL, notGR. Every Eurostat region code for Greece starts with EL. Join against an ISO-coded table and Greece silently vanishes. - NUTS versions change boundaries. The 2024 NUTS revision renamed and re-cut regions; a new code letter can mean new borders, and the old series does not transfer. Never stitch an old code's history onto a new code by name.
- Mixed levels in one column. Country, NUTS-1, NUTS-2 and NUTS-3 rows share the geo dimension. Length-5 codes = NUTS-3. Forget the filter once and your "district average" includes whole countries.
- Missing is missing.
:in downloads and absent keys in the API mean "not published". Interpolating over them produces confident nonsense — show the gap.