If you work with payments, trades or counterparty lists, you end up holding one identifier and needing another. A bank shows up as a BIC (DEUTDEFFXXX), a bond or share as an ISIN (US0378331005), and the regulator, the KYC team or the reporting template wants the LEI, the 20-character Legal Entity Identifier.
The Global Legal Entity Identifier Foundation (GLEIF) runs a free, public API that holds those mappings. No key, no signup. This post walks through:
- validating LEI, ISIN and BIC strings offline, so typos never reach the API;
- BIC → LEI in batches;
- ISIN → LEI, and why that one has to go one code at a time;
- enriching a CSV with LEI, legal name, country and LEI status.
Everything uses requests and the standard library (Python 3.10+).
The API in one minute
The endpoint is https://api.gleif.org/api/v1/lei-records. It returns JSON:API documents, and you filter with query parameters:
https://api.gleif.org/api/v1/lei-records?filter[bic]=DEUTDEFFXXX
https://api.gleif.org/api/v1/lei-records?filter[isin]=US0378331005
Two things from GLEIF's API documentation matter here:
- Rate limit: "Rate limiting is currently set at 60 requests, per minute, per user, for all users." Above that you get HTTP 429.
- ISIN mappings come from the ISIN-to-LEI mapping process run with the Association of National Numbering Agencies (ANNA). Not every ISIN has one.
GLEIF's terms of use say the data "are provided under the CC0 licence", so you can store and reuse the results.
Step 1: validate before you call
All three identifiers can be checked offline. An LEI ends in two check digits (ISO 17442: read the code as a number with A=10 … Z=35, and it must leave remainder 1 when divided by 97). An ISIN ends in a Luhn check digit (ISO 6166). A BIC has no check digit, but it does have a fixed shape, and it has a catch: GLEIF stores 11-character BICs, so the common 8-character form DEUTDEFF returns nothing until you append XXX.
# identifiers.py
import re
LEI_RE = re.compile(r"^[A-Z0-9]{18}[0-9]{2}$")
ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
BIC_RE = re.compile(r"^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$")
def _letters_to_digits(s: str) -> str:
"""A=10, B=11, ... Z=35; digits stay as they are."""
return "".join(str(int(c, 36)) for c in s)
def is_valid_lei(lei: str) -> bool:
"""ISO 17442: the 20-character code, read as a number, mod 97 must equal 1."""
lei = lei.strip().upper()
return bool(LEI_RE.match(lei)) and int(_letters_to_digits(lei)) % 97 == 1
def is_valid_isin(isin: str) -> bool:
"""ISO 6166: convert letters to numbers, then run the Luhn check."""
isin = isin.strip().upper()
if not ISIN_RE.match(isin):
return False
digits = _letters_to_digits(isin)
total = 0
for i, d in enumerate(reversed(digits)):
n = int(d)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
def normalize_bic(bic: str) -> str | None:
"""Uppercase, drop spaces, and expand 8-character BICs to 11 with 'XXX'."""
bic = bic.replace(" ", "").strip().upper()
if not BIC_RE.match(bic):
return None
return bic + "XXX" if len(bic) == 8 else bic
A quick check, with one deliberate typo in the last digit of each code:
LEI 7LTWFZYICNSX8D621K86: True
LEI 7LTWFZYICNSX8D621K87: False
ISIN US0378331005: True
ISIN US0378331006: False
BIC 'deutdeff' -> DEUTDEFFXXX
BIC 'BNPA FRPP XXX' -> BNPAFRPPXXX
BIC 'not-a-bic' -> None
Step 2: BIC → LEI, in batches
The bic filter accepts a comma-separated list, and every LEI record lists the BICs mapped to it in attributes.bic. That means one request can resolve up to a page of codes, and you can match each result back to the code you asked for. (Deutsche Bank's record, for example, carries more than 70 BICs, so check membership rather than assuming one BIC per record.)
# gleif.py
import time
import requests
from identifiers import is_valid_isin, normalize_bic
API = "https://api.gleif.org/api/v1/lei-records"
session = requests.Session()
session.headers["Accept"] = "application/vnd.api+json"
def get(params: dict) -> dict:
"""GET with a polite retry on HTTP 429 (GLEIF allows 60 requests a minute)."""
for attempt in range(4):
r = session.get(API, params=params, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 10)) + attempt * 5)
continue
r.raise_for_status()
return r.json()
raise RuntimeError("Still rate-limited after 4 attempts")
def summarize(record: dict) -> dict:
a = record["attributes"]
return {
"lei": a["lei"],
"legal_name": a["entity"]["legalName"]["name"],
"country": a["entity"]["legalAddress"]["country"],
"lei_status": a["registration"]["status"], # ISSUED, LAPSED, ...
"next_renewal": a["registration"]["nextRenewalDate"][:10],
}
def bics_to_lei(bics: list[str], chunk: int = 50) -> dict[str, dict | None]:
wanted = {b: normalize_bic(b) for b in bics}
found: dict[str, dict] = {}
codes = sorted({c for c in wanted.values() if c})
for i in range(0, len(codes), chunk):
part = codes[i:i + chunk]
data = get({"filter[bic]": ",".join(part), "page[size]": 200})["data"]
for rec in data:
for code in rec["attributes"].get("bic") or []:
if code in part:
found[code] = summarize(rec)
return {b: found.get(c) if c else None for b, c in wanted.items()}
Step 3: ISIN → LEI, one at a time
The isin filter also takes a list, but the LEI record that comes back doesn't contain its ISINs. With three ISINs in, you get three records out and no reliable way to say which ISIN produced which record. So for ISINs, make one request per code and pace yourself under the rate limit:
def isin_to_lei(isin: str) -> dict | None:
isin = isin.strip().upper()
if not is_valid_isin(isin):
return None
data = get({"filter[isin]": isin})["data"]
return summarize(data[0]) if data else None
(If you need the reverse direction, GLEIF also serves all ISINs for one LEI at /lei-records/{lei}/isins.)
Running both:
for bic, hit in bics_to_lei(["DEUTDEFF", "BNPAFRPPXXX", "ZZZZGB2LXXX"]).items():
print(f"{bic:<12} -> {hit['lei'] + ' ' + hit['legal_name'] if hit else 'no LEI found'}")
for isin in ["US0378331005", "DE0007164600", "GB0002634946"]:
hit = isin_to_lei(isin)
print(f"{isin} -> {hit['lei'] + ' ' + hit['legal_name'] if hit else 'no LEI found'}")
Output on 26 October 2026:
DEUTDEFF -> 7LTWFZYICNSX8D621K86 DEUTSCHE BANK AKTIENGESELLSCHAFT
BNPAFRPPXXX -> R0MUWSFPU8MPRO8K5P83 BNP PARIBAS
ZZZZGB2LXXX -> no LEI found
US0378331005 -> HWUPKR0MPOU8FGXBT394 Apple Inc.
DE0007164600 -> 529900D6BF99LW9R2E68 SAP SE
GB0002634946 -> 8SVCSVKSGDWMW2QHOH83 BAE SYSTEMS PLC
Note that the 8-character DEUTDEFF matched only because normalize_bic padded it.
Step 4: enrich a CSV
The usual real-world input is a spreadsheet export with a bic column, an isin column, or both. This script adds the LEI fields plus a match column, so you can see why a row didn't match:
# map_csv.py -- usage: python map_csv.py input.csv output.csv
import csv
import sys
import time
from gleif import bics_to_lei, isin_to_lei
from identifiers import is_valid_isin, normalize_bic
FIELDS = ["lei", "legal_name", "country", "lei_status", "next_renewal", "match"]
def main(src: str, dst: str) -> None:
with open(src, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
bic_hits = bics_to_lei([r["bic"] for r in rows if r.get("bic")])
isin_hits = {}
for isin in {r["isin"] for r in rows if r.get("isin")}:
isin_hits[isin] = isin_to_lei(isin)
time.sleep(1.1) # stay under 60 requests a minute
for r in rows:
hit, how = None, ""
if r.get("bic") and bic_hits.get(r["bic"]):
hit, how = bic_hits[r["bic"]], "bic"
elif r.get("isin") and isin_hits.get(r["isin"]):
hit, how = isin_hits[r["isin"]], "isin"
if not hit and r.get("isin") and not is_valid_isin(r["isin"]):
how = "invalid_isin"
elif not hit and r.get("bic") and not normalize_bic(r["bic"]):
how = "invalid_bic"
r.update({k: (hit or {}).get(k, "") for k in FIELDS[:-1]})
r["match"] = how or "none"
with open(dst, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
print(f"{sum(r['match'] in ('bic', 'isin') for r in rows)}/{len(rows)} rows matched -> {dst}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Input:
name,bic,isin
Deutsche Bank,DEUTDEFF,
BNP Paribas,BNPAFRPPXXX,
Apple,,US0378331005
SAP,,DE0007164600
Typo example,,US0378331006
Unknown BIC,ZZZZGB2LXXX,
Output (26 October 2026):
4/6 rows matched -> out.csv
name,bic,isin,lei,legal_name,country,lei_status,next_renewal,match
Deutsche Bank,DEUTDEFF,,7LTWFZYICNSX8D621K86,DEUTSCHE BANK AKTIENGESELLSCHAFT,DE,ISSUED,2027-06-02,bic
BNP Paribas,BNPAFRPPXXX,,R0MUWSFPU8MPRO8K5P83,BNP PARIBAS,FR,ISSUED,2027-02-06,bic
Apple,,US0378331005,HWUPKR0MPOU8FGXBT394,Apple Inc.,US,ISSUED,2027-03-08,isin
SAP,,DE0007164600,529900D6BF99LW9R2E68,SAP SE,DE,ISSUED,2027-01-26,isin
Typo example,,US0378331006,,,,,,invalid_isin
Unknown BIC,ZZZZGB2LXXX,,,,,,,none
Things to watch
-
lei_statusmatters. An LEI that isn't renewed on time shows asLAPSED. It still identifies the entity, but if the LEI is going into a regulatory report, check whether a lapsed one is acceptable there. Keeping the status andnext_renewalcolumns, not only the code, makes that easy. -
No match isn't an error. Plenty of BICs and ISINs have no LEI mapping. Keep the
nonerows so someone can look at them. -
Branch BICs. A BIC with a branch code (last three characters other than
XXX) only matches if that exact BIC is in the mapping. If it isn't, you can retry with theXXXform, but then record that the match came from the head-office BIC, not the branch. - Cache. For large lists, store results and re-check on a schedule instead of calling the API for every run.
- Bulk alternative. For very large lists, GLEIF also publishes downloadable BIC-to-LEI and ISIN-to-LEI relationship files, so you can join locally instead of calling the API.
This article was written with the help of AI. All code was run against the live GLEIF API on 26 October 2026, and the output shown is real (trimmed where noted).
Top comments (0)