If you build anything that touches the Russian market — invoicing, KYC, a marketplace onboarding flow, an accounting integration — sooner or later a user pastes a tax ID into one of your form fields. Before you spend a network call (or money) on a lookup API, you can catch most typos on the spot: every Russian business identifier carries a built-in checksum. This post walks through the four you'll meet most often and gives you copy-pasteable Python for each.
None of this is secret or scraped — the algorithms are published in the official registry specs. But they're scattered across PDFs in Russian, so here they are in one place, in English, with tests.
The identifiers, at a glance
| ID | Length | Belongs to | Check digits |
|---|---|---|---|
| INN | 10 | legal entity (company) | 1 |
| INN | 12 | individual / sole proprietor (ИП) | 2 |
| OGRN | 13 | legal entity registration number | 1 |
| OGRNIP | 15 | sole proprietor registration number | 1 |
| SNILS | 11 | personal insurance account number | 2 |
(There's also KPP — 9 chars — but it has no checksum, only a structural format, so there's nothing to verify beyond a regex.)
INN — the taxpayer number
A 10-digit INN belongs to a company; a 12-digit INN belongs to an individual or a sole proprietor. Each length has its own scheme.
10-digit INN. Multiply the first 9 digits by a fixed weight vector, sum, take mod 11, then mod 10. That's the 10th digit.
def valid_inn10(inn: str) -> bool:
if len(inn) != 10 or not inn.isdigit():
return False
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
d = [int(c) for c in inn]
check = (sum(w * x for w, x in zip(weights, d)) % 11) % 10
return check == d[9]
12-digit INN. Two check digits. The 11th digit is computed from the first 10 with one weight vector; the 12th from the first 11 with another.
def valid_inn12(inn: str) -> bool:
if len(inn) != 12 or not inn.isdigit():
return False
d = [int(c) for c in inn]
w11 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
w12 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
c11 = (sum(w * x for w, x in zip(w11, d[:10])) % 11) % 10
c12 = (sum(w * x for w, x in zip(w12, d[:11])) % 11) % 10
return c11 == d[10] and c12 == d[11]
def valid_inn(inn: str) -> bool:
return valid_inn10(inn) if len(inn) == 10 else valid_inn12(inn)
OGRN / OGRNIP — the registration numbers
Where INN is the tax number, OGRN is the registration number a company gets when it's entered into the state register (EGRUL). The checksum here is refreshingly simple: read all-but-the-last digit as one big integer, take a modulo, then mod 10.
OGRN (13 digits): mod 11.
OGRNIP (15 digits): mod 13.
def valid_ogrn(ogrn: str) -> bool:
if len(ogrn) != 13 or not ogrn.isdigit():
return False
check = (int(ogrn[:12]) % 11) % 10
return check == int(ogrn[12])
def valid_ogrnip(ogrn: str) -> bool:
if len(ogrn) != 15 or not ogrn.isdigit():
return False
check = (int(ogrn[:14]) % 13) % 10
return check == int(ogrn[14])
SNILS — the personal insurance number
SNILS is for individuals (the pension/insurance account). The first 9 digits are the number; the last 2 are the checksum. Multiply the 9 digits by descending weights 9..1, sum, and map:
- sum < 100 -> checksum is the sum
- sum == 100 or 101 -> checksum is 00
- sum > 101 -> take sum % 101, and if that is 100 or 101 -> 00
def valid_snils(snils: str) -> bool:
digits = snils.replace("-", "").replace(" ", "")
if len(digits) != 11 or not digits.isdigit():
return False
body = [int(c) for c in digits[:9]]
control = int(digits[9:])
s = sum(x * (9 - i) for i, x in enumerate(body))
if s in (100, 101):
s = 0
elif s > 101:
s %= 101
if s in (100, 101):
s = 0
return s == control
(One caveat: the SNILS checksum is only defined for numbers above 001-001-998, which covers every real one you'll ever be handed.)
A quick sanity test
assert valid_inn("7707083893") # Sberbank, 10-digit
assert valid_ogrn("1027700132195") # Sberbank, 13-digit
assert not valid_inn("7707083890") # last digit flipped -> caught
assert not valid_ogrn("1027700132190")
print("all good")
Flip any single digit and the check fails — which is exactly the point: you reject fat-fingered input in microseconds, client-side, before it ever costs you an API call.
All four validators, plus KPP structural checks and region-code parsing, are packaged as a tiny dependency-free Python library (MIT-licensed). Full source and tests on GitHub: https://github.com/kontragentpro/inn-tools
Where validation stops
Here's the honest limit of everything above: a checksum only proves the number is well-formed, not that it exists or that the company behind it is real, active, and solvent. 7707083893 passes the INN check, but the algorithm has no idea whether that company is bankrupt, has tax debt, or was struck off last week. For that you need the actual registry — EGRUL/EGRIP, the tax service, court and bankruptcy databases.
That second half is the problem I've spent the last while on. I run KontragentPro, a service that takes an INN or OGRN and returns the full picture — registry data, financials, court cases, bankruptcy and enforcement records, an AI risk score — for the Russian SMB market. The validators above are the doorstep; the registry plumbing behind them is where it gets interesting, and maybe a future post.
Until then: validate the format first. It's free, it's instant, and it turns a whole class of "invalid INN" support tickets into a form error your user fixes themselves.
Top comments (0)