Map an LEI to a PermID with LSEG in Python

API
Python
Tutorial
Code
A Python script that finds the LSEG PermID of each LEI in a list. On 24 test LEIs, it caught 2 typos and found the right company for the other 22.
Published

October 25, 2026

Sometimes you have a list of LEIs, Legal Entity Identifiers, that you would like to use in LSEG to get company data. The problem is that LSEG uses its own number, the PermID, to identify companies, so you first need the PermID of each LEI.

This is a tutorial and a script for anyone to use, given that they have an LSEG license and Workspace running.

Step 1. Setting up the session

I set the file names and the batch size, and I open the session.

from pathlib import Path

import httpx
import lseg.data as ld
import pandas as pd

# Path.cwd() is the folder this notebook runs in, and / joins a folder and a file name
FOLDER = Path.cwd()
LEI_FILE = FOLDER / "leis.csv"               # your LEIs, in a column named lei
PERMID_FILE = FOLDER / "lei_permids.csv"     # the result, written below
BATCH_SIZE = 100                             # LEIs per request
# Used when leis.csv is missing: 25 entries from GLEIF's public LEI register, read on 25 October 2026.
# The last three are written wrongly on purpose, to show what the code does with such entries
EXAMPLE_LEIS = [
    "HWUPKR0MPOU8FGXBT394",     # Apple Inc., United States
    "INR2EJN1ERAN0W5ZP974",     # Microsoft Corporation, United States
    "549300W9JLPW15XIFM52",     # Telefonaktiebolaget LM Ericsson, Sweden
    "549300HGV012CNC8JD22",     # Aktiebolaget Volvo, Sweden
    "549300VEBQPHRZBKUX38",     # Investor Aktiebolag, Sweden
    "529900O5RR7R39FRDM42",     # H & M Hennes & Mauritz AB, Sweden
    "213800TB53ELEUKM7Q61",     # Vodafone Group Public Limited Company, United Kingdom
    "MLU0ZO3ML4LN2LL2TL39",     # HSBC Holdings plc, United Kingdom
    "21380068P1DRHMJ8KU70",     # Shell plc, United Kingdom
    "W38RGI023J3WT1HWRP32",     # Siemens Aktiengesellschaft, Germany
    "7LTWFZYICNSX8D621K86",     # Deutsche Bank Aktiengesellschaft, Germany
    "529900S21EQ1BO4ESM68",     # TotalEnergies SE, France
    "KY37LUS27QQX7BB93L28",     # Nestlé S.A., Switzerland
    "549300SZJ9VS8SGXAN81",     # UBS Group AG, Switzerland
    "724500Y6DUVHQD6OXN27",     # ASML Holding N.V., Netherlands
    "549300DAQ1CVT6CXN342",     # Novo Nordisk A/S, Denmark
    "5493006W3QUS5LMH6R84",     # Toyota Motor Corporation, Japan
    "ES7IP3U3RHIGC71XBU11",     # Royal Bank of Canada, Canada
    "549300G81RQKP7XW2N18",     # Apple Operations International Limited, Ireland, not listed
    "549300506SI9CRFV9Z86",     # Credit Suisse Group AG, Switzerland, retired when UBS took it over
    "549300RTWP0GIJZL7V79",     # Microsoft Corporation Savings Plus 401(k) Plan, a pension plan
    "254900KZR24L5GN8TE52",     # Apple Ford, Inc., a car dealer whose LEI has lapsed
    "HWUPKR0MPOU8FGXBT395",     # Apple's LEI with its last digit changed
    "HWUPKR0MPOU8FGXBT39",      # Apple's LEI with its last digit missing
    " 549300w9jlpw15xifm52 ",   # Ericsson's LEI in lower case, with spaces
]


def check_digits_hold(lei):
    """The last two characters of an LEI are check digits (ISO 17442). With each letter written as a
    number, A as 10 up to Z as 35, the LEI read as one long number leaves a remainder of 1 when
    divided by 97. int(character, 36) gives that number for a digit or a letter."""
    return int("".join(str(int(character, 36)) for character in lei)) % 97 == 1


def open_lseg():
    """Open the LSEG session. lseg-data 2.1.1 passes proxy={} to httpx, which httpx 0.28 and later
    reject with "'dict' object has no attribute 'url'". On that error the empty dictionary is
    turned into no proxy, and the session is opened again."""
    ld.get_config().set_param("http.request-timeout", 300)    # seconds, read when the session opens
    try:
        ld.open_session()
    except AttributeError:
        for client in (httpx.Client, httpx.AsyncClient):
            original = client.__init__

            def patched(self, *args, _original=original, **kwargs):
                if isinstance(kwargs.get("proxy"), dict):
                    kwargs["proxy"] = next((value for value in kwargs["proxy"].values()
                                            if value), None)
                return _original(self, *args, **kwargs)
            client.__init__ = patched
        ld.open_session()

Step 2. Processing the LEIs

First, I clean the list of LEIs. I examine whether each of them has 20 letters and digits. A correct LEI gives a remainder of 1 if you turn the letters into numbers and divide the whole LEI by 97.

\[\text{the LEI read as one number} \bmod 97 = 1\]

Then I pass each LEI to LSEG with @LEI and ask for TR.OrganizationID, the PermID.

# Your LEIs, or the example when leis.csv is missing
if LEI_FILE.exists():
    entries = pd.read_csv(LEI_FILE, dtype=str)["lei"]
else:
    entries = pd.Series(EXAMPLE_LEIS)
# str.strip removes spaces at either end, str.upper writes the letters as capitals, and unique()
# keeps each LEI once
leis = entries.dropna().str.strip().str.upper().unique()
# An LEI has 20 characters, letters and digits only, and its check digits hold. isalnum() is True
# for a text of letters and digits only. Any other entry is a typo, which is listed and not sent
valid = [lei for lei in leis if len(lei) == 20 and lei.isalnum() and check_digits_hold(lei)]
print(f"LEIs read: {len(leis)}, of which {len(valid)} have 20 letters and digits and valid check digits")
for lei in leis:
    if lei not in valid:
        print(f"Not sent, a typo: {lei}")

open_lseg()
tables = []
# range(0, n, BATCH_SIZE) gives 0, 100, 200, ..., and valid[first:first + BATCH_SIZE] is one batch
for first in range(0, len(valid), BATCH_SIZE):
    batch = valid[first:first + BATCH_SIZE]
    # "<LEI>@LEI" asks for the organization with that LEI. TR.OrganizationID is the organization's
    # PermID, and TR.CommonName its name, to check the match
    table = ld.get_data(universe=[f"{lei}@LEI" for lei in batch],
                        fields=["TR.OrganizationID", "TR.CommonName"])
    tables.append(table)
ld.close_session()

# concat stacks the batches. The columns are named by position, because LSEG names them for display
result = pd.concat(tables, ignore_index=True)
assert result.shape[1] == 3, f"expected three columns, got {list(result.columns)}"
result.columns = ["instrument", "organization_permid", "name"]
result["lei"] = result["instrument"].str.replace("@LEI", "", regex=False)
# An LEI without a match comes back with an empty PermID
permid_text = result["organization_permid"].astype("string").str.strip()
found = permid_text.notna() & (permid_text != "")
print(f"LEIs with a PermID: {found.sum()} of {len(result)}")
result[["lei", "organization_permid", "name"]].to_csv(PERMID_FILE, index=False)
print(f"Saved {PERMID_FILE.name}: one row per LEI, with its PermID and name")
LEIs read: 24, of which 22 have 20 letters and digits and valid check digits
Not sent, a typo: HWUPKR0MPOU8FGXBT395
Not sent, a typo: HWUPKR0MPOU8FGXBT39
LEIs with a PermID: 22 of 22
Saved lei_permids.csv: one row per LEI, with its PermID and name

As a check, I tried 24 LEIs from GLEIF, the public LEI register, because based on that list, I know which one belongs to which company. Two out of the 24 had a typo, and the remaining ones came back with the right company. In my first run, without the check digits, LSEG did not raise any error and gave me an empty row for the typo it received. So that is why the checks are necessary, to ensure that you are getting non-empty rows.

Limitations

It is important to be mindful that an LEI corresponds to one company and is fixed. If that company merges with another company, you may not be able to use the LEI to get updated data.

Conclusion

So the conclusion is that this is a full script. You start with a list of LEIs, get each company’s PermID from LSEG, and then use it to get company data.