How to Validate a VIN Check Digit in Python
Position 9 of a 17-character VIN is a mod-11 checksum defined in 49 CFR 565.15. Here is the full algorithm — transliteration table, weights, and the X — as tested Python code.
What the VIN check digit is
Every vehicle built since 1981 carries a 17-character Vehicle Identification Number. The characters are digits and letters, with three letters banned outright — I, O and Q — because they look like 1 and 0. The ninth character is special: for vehicles made for the North American market, US federal regulation 49 CFR 565.15 (implementing FMVSS 115, aligned with ISO 3779) requires it to be a check digit computed from the other sixteen characters. Rerun the computation on a VIN someone typed into your form and you catch most typos and many fabricated numbers instantly — no database call needed.
If you just want to check one number right now, paste it into our free VIN validator. If you want the general theory of why checksums catch typos, see how check digits work. The rest of this page builds the VIN-specific algorithm in Python.
The algorithm in three steps
- Transliterate: convert each of the 17 characters to a numeric value. Digits keep their face value; letters map through this table from 565.15:
| A=1 | B=2 | C=3 | D=4 | E=5 | F=6 | G=7 | H=8 |
|---|---|---|---|---|---|---|---|
| J=1 | K=2 | L=3 | M=4 | N=5 | P=7 | R=9 | |
| S=2 | T=3 | U=4 | V=5 | W=6 | X=7 | Y=8 | Z=9 |
Note the traps in that table: the second row skips 6 and 8 entirely (P is 7, R is 9), and the third row starts at S=2, not 1. Copying the values by memory instead of from the regulation is the single most common source of buggy VIN validators.
- Weight: multiply each value by its position weight:
8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2. Position 9 — the check digit itself — gets weight 0, so it never influences its own calculation. - Mod 11: sum the 17 products and take the remainder mod 11. A remainder of 0–9 is the check digit as-is; a remainder of 10 is written as X. The VIN passes if this equals the character actually sitting in position 9.
Complete Python implementation
No dependencies beyond the standard library. Tested on Python 3.
import re
TRANSLITERATION = {
"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8,
"J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9,
"S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9,
}
WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
def vin_check_digit(vin: str) -> str:
"""Return the expected position-9 check digit ('0'-'9' or 'X')."""
vin = re.sub(r"[^A-Za-z0-9]", "", vin).upper()
if len(vin) != 17:
raise ValueError(f"VIN must be 17 characters, got {len(vin)}")
if re.search(r"[IOQ]", vin):
raise ValueError("VIN may not contain the letters I, O, or Q")
total = 0
for i, ch in enumerate(vin):
value = int(ch) if ch.isdigit() else TRANSLITERATION[ch]
total += value * WEIGHTS[i]
remainder = total % 11
return "X" if remainder == 10 else str(remainder)
def validate_vin(vin: str) -> bool:
"""True if the VIN's 9th character matches its computed check digit."""
cleaned = re.sub(r"[^A-Za-z0-9]", "", vin).upper()
return vin_check_digit(cleaned) == cleaned[8]
print(validate_vin("1M8GDM9AXKP042788")) # True
print(vin_check_digit("1HGCM82633A004352")) # 3 (matches position 9)
print(validate_vin("11111111111111111")) # True (classic test vector)
Walking through it: the regex strips spaces and hyphens (VINs are often pasted as 1M8-GDM9AX-KP042788) and .upper() normalizes lowercase input. The two hard rejections — wrong length and I/O/Q — come before any arithmetic, because no genuine VIN anywhere in the world violates them. Then each character is transliterated, multiplied by its weight, summed, and reduced mod 11. The comparison at the end happens between strings, which is what makes the X case work with no special handling.
Worked example: 1M8GDM9AXKP042788
This is a known-valid VIN whose check digit happens to be the interesting one, X. Position by position:
| Pos | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Char | 1 | M | 8 | G | D | M | 9 | A | X | K | P | 0 | 4 | 2 | 7 | 8 | 8 |
| Value | 1 | 4 | 8 | 7 | 4 | 4 | 9 | 1 | 7 | 2 | 7 | 0 | 4 | 2 | 7 | 8 | 8 |
| Weight | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 10 | 0 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 |
| Product | 8 | 28 | 48 | 35 | 16 | 12 | 18 | 10 | 0 | 18 | 56 | 0 | 24 | 10 | 28 | 24 | 16 |
Sum of products: 8+28+48+35+16+12+18+10+0+18+56+0+24+10+28+24+16 = 351. Then 351 mod 11 = 10 (since 11 × 31 = 341), and a remainder of 10 is written as X. Position 9 of the VIN is indeed X, so validate_vin returns True. Note how the X in position 9 was transliterated to 7 in the value row but then multiplied by weight 0 — the check digit participates in the loop yet contributes nothing, which is exactly what the standard intends.
Edge cases and classic mistakes
- Comparing as integers.
int(vin[8])throws on the roughly 1-in-11 VINs whose check digit is X. Always compare strings. - Skipping input cleaning. Real-world VINs arrive lowercase, with spaces, or with hyphens from registration documents. Strip non-alphanumerics and uppercase before anything else — but after cleaning, insist on exactly 17 characters. Padding or truncating to force a fit hides data-entry errors instead of catching them.
- Mistyped transliteration values. P=7 (not 6), R=9 (not 8), S=2 (not 1). If your validator rejects known-good VINs containing those letters, this is almost always why.
- All-digit VINs and spreadsheets. A VIN like
11111111111111111is arithmetically valid, and Excel will happily mangle it into scientific notation or strip meaningful zeros the moment the column is numeric. Store VINs as text — the same problem (and fix) as in our leading zeros guide. - Forgetting the I/O/Q rule. Rejecting those letters up front costs one regex and catches OCR misreads (0↔O, 1↔I) that the checksum alone might let through.
A failing VIN is not necessarily a fake VIN
Here is the honest limitation, and it is bigger for VINs than for most identifiers. The check digit is mandatory only for vehicles built for the North American market. ISO 3779 lists it as optional, and most European and many Asian manufacturers simply use position 9 as an ordinary attribute character. A genuine Fiat or BMW VIN can fail this test while a carefully forged one passes it. So treat the result asymmetrically: for a US- or Canada-market vehicle a failure is a strong signal of a typo or tampering; for a European VIN it proves nothing by itself. And in either case a pass only means internal consistency — it does not prove the VIN was ever assigned to a real vehicle. For that you need a registry lookup (for example NHTSA's free vPIC decoder for US vehicles). Our VIN validator applies the structural rules plus the checksum and flags this regional caveat explicitly.
Validating at scale?
If VINs arrive by the thousands — dealer feeds, insurance intakes, fleet imports — the same algorithm is available as a JSON API that accepts up to 100 VINs per call:
curl -X POST https://codeclassify-api.rosariovitale0096.workers.dev/v1/vin/validate \
-H "X-Api-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"vins":["1M8GDM9AXKP042788","1HGCM82633A004352"]}'
Response:
{"ok":true,"count":2,"valid":2,
"standard":"ISO 3779 / FMVSS 115 (check digit)",
"results":[
{"input":"1M8GDM9AXKP042788","vin":"1M8GDM9AXKP042788","valid":true,"expected_check_digit":"X"},
{"input":"1HGCM82633A004352","vin":"1HGCM82633A004352","valid":true,"expected_check_digit":"3"}]}
Invalid entries come back with a reason (wrong_length, contains_IOQ, bad_format) or the expected_check_digit they should have had. The free tier includes 10 calls per month — grab a key on the API page. For validating a whole spreadsheet in one go, see the CSV bulk-validation guide.
FAQ
Why can't a VIN contain the letters I, O, or Q?
ISO 3779 and 49 CFR 565 exclude I, O and Q from every position of a VIN because they are too easy to confuse with the digits 1 and 0 on stamped plates and handwritten paperwork. This rule applies worldwide, so a 17-character string containing any of those three letters is not a valid VIN anywhere — you can reject it before even computing the checksum.
My VIN fails the check digit — is the vehicle fake?
Not necessarily. The position-9 check digit is mandatory for vehicles built for the North American market under 49 CFR 565 (and Canada's equivalent rules). ISO 3779, the international standard, treats it as optional, so many European- and Asian-market VINs are genuine yet fail the mod-11 test. A failure is a strong red flag for a US or Canadian vehicle but inconclusive for one sold elsewhere. To confirm a VIN actually belongs to a real vehicle you need a registry decoder, not a checksum.
What does an X in position 9 of a VIN mean?
The VIN checksum is computed mod 11, so the remainder can be anything from 0 to 10. Because 10 does not fit in one character, the standard writes it as the letter X. An X in position 9 is therefore perfectly normal — roughly one VIN in eleven has it. Remember to compare it as a string in code: converting the check digit to an integer will crash on X.
Check a VIN without writing code
Paste any 17-character VIN into the free VIN validator — it runs the length, character and mod-11 checks above and tells you the expected check digit when one doesn't match.
This guide is for general information only. A passing check digit confirms a VIN is internally consistent, not that it is assigned to a real vehicle, and non-North-American VINs may be genuine without a valid check digit. For vehicle history or title decisions, consult an official registry or decoder such as NHTSA vPIC.