Guide · Python · Validation

VIN Validation in Python — Check Digit, WMI, and Model Year

Every car has a 17-character VIN that encodes manufacturer, vehicle type, and model year — plus a check digit calculated with a transliteration table and positional weights that most developers have never seen before.

1. What is a VIN?

A Vehicle Identification Number (VIN) is a 17-character alphanumeric code that uniquely identifies a motor vehicle. Standardised by ISO 3779 and mandated by NHTSA in the US since 1981, every new car, truck, and motorcycle sold globally carries a VIN stamped on a plate visible through the windshield and encoded in official documents.

VINs are used by auto dealerships, insurance companies, DMVs, and marketplaces to track vehicle history, verify odometer readings, check for recalls, and confirm ownership. Validating a VIN is the first step before any of these lookups.


2. VIN anatomy — WMI, VDS, VIS

A VIN is divided into three sections, each carrying structured information:

1G1·JC5213·3B14602
1G1 = WMI (1–3)JC5213 = VDS (4–9)5 = check digit (pos 9)3B14602 = VIS (10–17)

WMI — World Manufacturer Identifier (positions 1–3)

Assigned by NHTSA/SAE. The first character indicates the country of manufacture (1–5 = USA, J = Japan, W = Germany, S = UK/Sweden/Spain, etc.). The three characters together identify the specific manufacturer — e.g. 1G1 is Chevrolet passenger cars made in the USA.

VDS — Vehicle Descriptor Section (positions 4–9)

Six characters defined by the manufacturer to describe the vehicle type, model, body style, engine type, and restraint system. Position 9 is always the check digit — calculated from all other 16 positions.

VIS — Vehicle Identifier Section (positions 10–17)

Eight characters that make each vehicle unique within a manufacturer. Position 10 encodes the model year using a letter/digit code. Position 11 identifies the plant where the vehicle was assembled. Positions 12–17 are the sequential production number.


3. The check digit algorithm — weights and transliteration

The VIN check digit algorithm (NHTSA, 49 CFR 565) is more complex than Luhn. It involves converting letters to numbers using a transliteration table, then multiplying each position by a positional weight, summing, and taking the result mod 11.

Step 1 — Transliterate letters to numbers

ABCDEFGHJKLMNPRSTUVWXYZ
12345678123457923456789

Letters I, O, Q are not allowed in VINs — they are too easily confused with 1, 0, and 0.

Step 2 — Multiply each position by its weight

Position1234567891011121314151617
Weight87654321098765432

Position 9 (check digit) has weight 0 — it is excluded from the sum.

Step 3 — Sum and take mod 11

Sum all (transliterated value × weight) for positions 1–8 and 10–17. Divide by 11 and take the remainder.

  • • Remainder 0–9 → check digit is that digit
  • • Remainder 10 → check digit is the letter X
# vin_check_digit.py — calculate VIN check digit
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,
    "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7,
    "8": 8, "9": 9,
}

WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]


def calculate_check_digit(vin: str) -> str:
    total = sum(
        TRANSLITERATION.get(char, 0) * WEIGHTS[i]
        for i, char in enumerate(vin)
    )
    remainder = total % 11
    return "X" if remainder == 10 else str(remainder)


vin = "1G1JC5213TB146027"  # Note: South American VINs skip this check
print(calculate_check_digit(vin))  # → should match vin[8] = '3'

4. Model year encoding — the 30-year cycle

Position 10 of the VIN encodes the model year using a 30-year repeating cycle of letters and digits. Letters I, O, Q, U, and Z are excluded (along with 0) to avoid ambiguity.

CodeFirst cycleSecond cycle
A19802010
B19812011
C19822012
D19832013
E19842014
F19852015
G19862016
H19872017
J19882018
K19892019
920092039
ℹ️For North American VINs (WMI starts with 1–5), a 2008 NHTSA amendment added position 7 to disambiguate the cycle: a numeric character at position 7 means the first cycle (1980–2009), alphabetic means the second (2010–2039). For non-North-American VINs, both years remain possible from position 10 alone.

5. Why VIN validation is tricky

Check digit is not universal

The NHTSA check digit algorithm applies only to North American VINs(WMI prefix 1–5). European and most Asian manufacturers do not use this check digit — the 9th position in their VINs can be any valid VIN character. Applying the check digit test to a BMW or Toyota VIN will produce false negatives.

Excluded characters

The letters I, O, and Q are never used in VINs because they are too easily confused with 1, 0, and 0. A VIN containing any of these characters is always invalid — regardless of the check digit.

Pre-1981 vehicles

Vehicles manufactured before 1981 may have VINs shorter than 17 characters with manufacturer-specific formats. The 17-character standardised format was mandated in the US from the 1981 model year onwards.


6. The production-ready solution

The IsValid VIN API validates the 17-character format, applies the check digit algorithm where applicable, and decodes the WMI into manufacturer, country, and region. The response also includes the model year (or year range) from position 10.

NHTSA
Algorithm
weights + transliteration + mod 11
<20ms
Response time
pure algorithmic check
100/day
Free tier
no credit card

Full parameter reference and response schema: VIN Validation API docs →


7. Python code example

Using the requests library — the de facto standard for HTTP in Python. Install it with pip install requests.

# vin_validator.py
import os
import requests

API_KEY = os.environ["ISVALID_API_KEY"]
BASE_URL = "https://api.isvalid.dev"


def validate_vin(vin: str) -> dict:
    """Validate a VIN using the IsValid API.

    Args:
        vin: Vehicle Identification Number (17 characters).

    Returns:
        Validation result with decoded fields as a dictionary.

    Raises:
        requests.HTTPError: If the API returns a non-2xx status.
    """
    response = requests.get(
        f"{BASE_URL}/v0/vin",
        params={"value": vin},
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    response.raise_for_status()
    return response.json()


# ── Example usage ────────────────────────────────────────────────────────────

result = validate_vin("1G1JC5213TB146027")

if not result["valid"]:
    print("Invalid VIN")
else:
    print(f"Manufacturer: {result['manufacturer']}")   # → 'Chevrolet'
    print(f"Country: {result['country']}")              # → 'United States'
    print(f"Region: {result['region']}")                # → 'North America'
    print(f"Model year: {result['modelYear']}")         # → [1996] or [1996, 2026]
    print(f"Check digit valid: {result['checkDigit']['valid']}")

In an automotive marketplace or insurance form with Flask:

# app.py (Flask)
from flask import Flask, request, jsonify

app = Flask(__name__)


@app.post("/vehicles")
def create_vehicle():
    data = request.get_json()

    try:
        check = validate_vin(data["vin"])
    except requests.RequestException:
        return jsonify(error="VIN validation service unavailable"), 502

    if not check["valid"]:
        return jsonify(error="Invalid VIN"), 400

    # Warn if check digit does not apply (non-North American VIN)
    if check["checkDigit"]["applicable"]:
        cd_note = "verified" if check["checkDigit"]["valid"] else "mismatch — possible transcription error"
    else:
        cd_note = "not applicable (non-North American VIN)"

    vehicle = db.vehicles.create(
        vin=check["normalized"],
        manufacturer=check["manufacturer"],
        country=check["country"],
        model_year=check["modelYear"][0] if check["modelYear"] else None,
        check_digit_status=cd_note,
        **{k: v for k, v in data.items() if k != "vin"},
    )

    return jsonify(vehicle_id=vehicle.id, manufacturer=check["manufacturer"])
Store result["normalized"] (uppercased, separators stripped) rather than the raw input. Expose the formatted VIN in groups of three using wmi + vds + vis for display purposes.

8. cURL example

North American VIN (check digit applies):

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.isvalid.dev/v0/vin?value=1G1JC5213TB146027"

European VIN (check digit not applicable):

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.isvalid.dev/v0/vin?value=WVWZZZ3CZDE014765"

Invalid VIN (contains forbidden letter O):

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.isvalid.dev/v0/vin?value=1G1JC5O13TB146027"

9. Understanding the response

Valid North American VIN:

{
  "valid": true,
  "normalized": "1G1JC5213TB146027",
  "wmi": "1G1",
  "vds": "JC5213",
  "vis": "3B146027",
  "region": "North America",
  "country": "United States",
  "manufacturer": "Chevrolet",
  "modelYear": [1996],
  "checkDigit": {
    "value": "3",
    "calculated": "3",
    "valid": true,
    "applicable": true
  }
}

Valid European VIN (check digit not applicable):

{
  "valid": true,
  "normalized": "WVWZZZ3CZDE014765",
  "wmi": "WVW",
  "vds": "ZZZ3CZ",
  "vis": "DE014765",
  "region": "Europe",
  "country": "Germany",
  "manufacturer": "Volkswagen",
  "modelYear": [1983, 2013],
  "checkDigit": {
    "value": "Z",
    "calculated": "5",
    "valid": false,
    "applicable": false
  }
}
FieldTypeDescription
normalizedstringUppercase VIN with separators stripped
wmistringPositions 1–3: World Manufacturer Identifier
vdsstringPositions 4–9: Vehicle Descriptor Section
visstringPositions 10–17: Vehicle Identifier Section
regionstring | nullGeographic region from the first WMI character
countrystring | nullCountry of manufacture from the WMI
manufacturerstring | nullManufacturer name if the WMI is in the known list
modelYearnumber[] | null1 or 2 possible model years from position 10
checkDigit.valuestringThe check digit character at position 9
checkDigit.calculatedstringThe expected check digit per NHTSA algorithm
checkDigit.validbooleanWhether value === calculated
checkDigit.applicablebooleanfalse for non-North American VINs — do not use checkDigit.valid if false

10. Edge cases

Distinguishing check digit mismatch from invalid VIN

For North American VINs, a check digit mismatch is a strong signal of a transcription error — but the API still returns valid: true because the format itself is correct. Use checkDigit.applicable && !checkDigit.valid to surface a specific warning to the user.

if result["valid"]:
    cd = result["checkDigit"]
    if cd["applicable"] and not cd["valid"]:
        # Warn: format is OK, but check digit does not match — possible typo
        show_warning("VIN check digit mismatch. Please double-check the number.")

Two possible model years

For non-North American VINs, modelYear may contain two elements — e.g. [1983, 2013]. Display both and let the user confirm, or cross-reference with the registration document.

Unknown manufacturer

manufacturer is null when the WMI is not in the known dataset. This is not an error — it just means the manufacturer is not in the lookup table (low-volume manufacturers use WMIs ending in 9 and share them across small brands). The VIN is still structurally valid.


Summary

Do not apply the check digit test to non-North American VINs
Do not reject a VIN just because the manufacturer is unknown
Reject any VIN containing I, O, or Q — they are never valid
For North American VINs, warn on check digit mismatch (likely a typo)
Store the normalised form (uppercase, no separators)
Show both possible model years when the VIN is non-North American

See also

Validate VINs instantly

Free tier includes 100 API calls per day. No credit card required. Returns manufacturer, country, region, model year, and check digit status.