DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on Originally published at emirmrkaljevic.github.io

How to validate a list of LEI codes in bulk with the free GLEIF API (Python)

If you work with counterparties, suppliers or KYC data, you've probably got a spreadsheet column full of LEIs (Legal Entity Identifiers, the 20-character IDs every regulated company needs to trade). Some of them are typos, some belong to companies that closed, and some lapsed because nobody paid the yearly renewal. GLEIF, the foundation that runs the global LEI system, publishes all of this through a free JSON API with no key, so checking a whole list takes one short script.

1. Catch typos before calling any API

An LEI is 18 letters or digits followed by 2 check digits (ISO 17442). The check uses the same MOD 97-10 trick as IBANs: turn each letter into a number (A=10 up to Z=35), read the result as one big integer, and it must leave a remainder of 1 when divided by 97.

def checksum_ok(lei: str) -> bool:
    digits = "".join(str(int(c, 36)) for c in lei)
    return int(digits) % 97 == 1
Enter fullscreen mode Exit fullscreen mode

int(c, 36) does the letter conversion for free, because base 36 maps 0-9 to themselves and A-Z to 10-35. This alone catches most copy-paste mistakes, like a swapped or changed last character.

2. Look up 200 LEIs per request

The lei-records endpoint accepts a comma-separated filter[lei], so you don't need one request per code:

GET https://api.gleif.org/api/v1/lei-records?filter[lei]=LEI1,LEI2,...&page[size]=200
Enter fullscreen mode Exit fullscreen mode

Anything valid that doesn't come back simply doesn't exist in the register.

3. The full script

Put one LEI per line in leis.txt and run python3 lei_check.py leis.txt > result.csv. It only needs requests.

import csv, re, sys, time
import requests

API = "https://api.gleif.org/api/v1/lei-records"
LEI_RE = re.compile(r"^[A-Z0-9]{18}[0-9]{2}$")

def checksum_ok(lei: str) -> bool:
    # ISO 17442 uses ISO 7064 MOD 97-10: letters become numbers (A=10 ... Z=35)
    digits = "".join(str(int(c, 36)) for c in lei)
    return int(digits) % 97 == 1

def lookup(leis):
    """Fetch up to 200 LEI records in one request."""
    r = requests.get(API, params={"filter[lei]": ",".join(leis), "page[size]": 200}, timeout=30)
    r.raise_for_status()
    return {rec["id"]: rec["attributes"] for rec in r.json()["data"]}

def main(path):
    raw = [l.strip().upper() for l in open(path) if l.strip()]
    valid = [l for l in raw if LEI_RE.match(l) and checksum_ok(l)]
    found = {}
    for i in range(0, len(valid), 200):
        found.update(lookup(valid[i:i + 200]))
        time.sleep(1)  # be polite to the free API
    w = csv.writer(sys.stdout)
    w.writerow(["lei", "status", "legal_name", "country", "entity_status", "registration_status", "next_renewal"])
    for lei in raw:
        if lei not in valid:
            w.writerow([lei, "invalid_format_or_checksum", "", "", "", "", ""])
        elif lei not in found:
            w.writerow([lei, "not_found", "", "", "", "", ""])
        else:
            a = found[lei]
            w.writerow([lei, "ok", a["entity"]["legalName"]["name"],
                        a["entity"]["legalAddress"]["country"], a["entity"]["status"],
                        a["registration"]["status"], (a["registration"]["nextRenewalDate"] or "")[:10]])

if __name__ == "__main__":
    main(sys.argv[1])
Enter fullscreen mode Exit fullscreen mode

Output for a small test file:

lei,status,legal_name,country,entity_status,registration_status,next_renewal
5493001KJTIIGC8Y1R12,ok,Bloomberg Finance L.P.,US,ACTIVE,ISSUED,2027-01-25
7LTWFZYICNSX8D621K86,ok,DEUTSCHE BANK AKTIENGESELLSCHAFT,DE,ACTIVE,ISSUED,2027-06-02
HWUPKR0MPOU8FGXBT394,ok,Apple Inc.,US,ACTIVE,ISSUED,2027-03-08
5493001KJTIIGC8Y1R13,invalid_format_or_checksum,,,,,
NOTANLEI,invalid_format_or_checksum,,,,,
Enter fullscreen mode Exit fullscreen mode

4. Read the two status fields correctly

They mean different things, and mixing them up is the most common mistake:

  • entity.status is about the company: ACTIVE or INACTIVE (merged, dissolved).
  • registration.status is about the LEI itself: ISSUED is fine, LAPSED means the renewal wasn't paid, RETIRED or ANNULLED means it shouldn't be used anymore.

A company can be perfectly active with a lapsed LEI, which is exactly the case your compliance team wants flagged. The next_renewal column tells you who is about to lapse.

5. Bonus: find an LEI from a company name

If you only have names, the same endpoint filters by legal name:

r = requests.get(API, params={"filter[entity.legalName]": "Siemens Aktiengesellschaft", "page[size]": 3}, timeout=30)
for d in r.json()["data"]:
    print(d["id"], d["attributes"]["entity"]["legalName"]["name"], d["attributes"]["entity"]["legalAddress"]["country"])
Enter fullscreen mode Exit fullscreen mode

This returns both the German parent and the Austrian subsidiary, so always check the country before you trust a name match.

Limits worth knowing

  • The API is free but rate limited (around 60 requests per minute), so keep the small sleep between batches.
  • GLEIF data is published under CC0, so you can store and reuse it.
  • Parent and child relationships live under separate relationships links on each record, if you need group structures.

If you'd rather not run it yourself

Disclosure: I built and maintain a hosted version of this on Apify, GLEIF LEI Lookup. It does the same checks plus name search and exports to CSV or Google Sheets, and invalid or not-found codes are free. For a one-off list, the script above is all you need.

Top comments (0)