Functional Weave
Code in Python

validation.vin

Check a 17-character vehicle identification number and its check digit; enforced for North American VINs.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 17 tests, run in TypeScript, Python and Rust.

What it does

Checks a vehicle identification number: the 17-character code stamped on the chassis and printed on a V5C logbook. ISO 3779 fixes the length and alphabet; the check digit in position 9 comes from the North American standard (US 49 CFR 565.15) and is the part that catches typos.

THE CHECK DIGIT IS NOT UNIVERSAL, and that decides the shape of the answer. North American law requires it, so a VIN whose world manufacturer identifier starts with 1 to 5 (United States, Canada, Mexico) and whose check digit does not match is `valid: false` with `bad-check-digit`. ISO 3779 itself does not require one, and many European and Japanese manufacturers put a letter or an arbitrary digit in position 9 for vehicles built for their home markets. Rejecting those would reject real cars on UK roads, so for every other region a mismatch leaves `valid: true` and reports `checkDigitMatches: false`. A caller who knows the vehicle should carry a check digit (anything built for sale in North America, and most Chinese VINs) can insist on `checkDigitMatches`.

For example

  • validate_vin(1M8GDM9AXKP042788) → valid true, normalised 1M8GDM9AXKP042788, wmi 1M8, check digit matches true, reason — the textbook VIN whose check digit is X (remainder ten)
  • validate_vin(1HGCM82633A004352) → valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason — a US-built Honda
  • validate_vin(11111111111111111) → valid true, normalised 11111111111111111, wmi 111, check digit matches true, reason — seventeen ones: the weights sum to 89, which leaves 1

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def validate_vin(value: str) -> VinCheck
valuestringa 17-character VIN in any case, optionally spaced
returnsVinCheck

The type it declares, generated into your project

@dataclass(frozen=True)
class VinCheck:
    """The result of checking a VIN. Strings and checkDigitMatches are null when valid is false; reason is null when it is true."""

    valid: bool
    #: upper case, no spaces
    normalised: Optional[str]
    #: the world manufacturer identifier, the first three characters
    wmi: Optional[str]
    #: whether position 9 holds the computed check digit
    check_digit_matches: Optional[bool]
    #: empty, bad-character, bad-length, forbidden-letter or bad-check-digit
    reason: Optional[str]

Your code names it in one line, in the file that uses it

from fune.validation.vin import validate_vin  # validation.vin@^1
impl/python.py · 56 lines · open · raw
from .validation_vin_types import VinCheck

#: Letter values from 49 CFR 565.15, indexed by letter A-Z. I, O and Q are
#: never used and hold -1; the others run 1-9 in three passes (A-I, J-R, S-Z)
#: with the forbidden letters' slots skipped.
_LETTER_VALUES = (1, 2, 3, 4, 5, 6, 7, 8, -1, 1, 2, 3, 4, 5, -1, 7, -1, 9, 2, 3, 4, 5, 6, 7, 8, 9)

#: Position weights; position 9 is the check digit itself and weighs nothing.
_WEIGHTS = (8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2)


def _invalid(reason: str) -> VinCheck:
    return VinCheck(valid=False, normalised=None, wmi=None, check_digit_matches=None, reason=reason)


def validate_vin(value: str) -> VinCheck:
    """Check a 17-character VIN.

    The check digit is enforced only for North American VINs (first character
    1-5), where the law requires it; elsewhere a mismatch is reported in
    check_digit_matches but does not make the VIN invalid, because many
    European and Asian VINs never carried one. Validators answer rather than
    raise.
    """
    if not isinstance(value, str):
        return _invalid("empty")

    # Folded by hand: str.upper() is Unicode-aware and Rust's ASCII fold is not.
    vin = "".join(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch for ch in value if ch != " ")
    if not vin:
        return _invalid("empty")
    if any(not ("0" <= ch <= "9" or "A" <= ch <= "Z") for ch in vin):
        return _invalid("bad-character")
    if len(vin) != 17:
        return _invalid("bad-length")

    total = 0
    for i, ch in enumerate(vin):
        if ch <= "9":
            n = ord(ch) - 48
        else:
            n = _LETTER_VALUES[ord(ch) - 65]
            # I, O and Q read as 1, 0 and 0, so the standard never issues them.
            if n < 0:
                return _invalid("forbidden-letter")
        total += n * _WEIGHTS[i]
    remainder = total % 11
    expected = "X" if remainder == 10 else str(remainder)
    matches = vin[8] == expected

    # WMIs starting 1-5 are North American, where 49 CFR 565 makes the check
    # digit mandatory; anywhere else it is optional, so only report it.
    if not matches and "1" <= vin[0] <= "5":
        return _invalid("bad-check-digit")

    return VinCheck(valid=True, normalised=vin, wmi=vin[:3], check_digit_matches=matches, reason=None)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add validation.vin
Download for Python validation.vin-1.0.0-python.fune · 11,545 bytes sha256 b0b54edc35ad39491a60811311505ca873c5cc126b32a9e33b872a73c8b0830a

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.vin-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.vin@1.0.0:python.

The whole function, every language, is one file too: validation.vin-1.0.0.fune, 17,748 bytes, sha256 310dc9072c78fe16ddf0e7f98d75b9535b807ba32c97aed2874a45be9b7b36c6. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before validation.vin

after — your function gets the result and the arguments, and returns the final result.

# fune: after validation.vin

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.vin --steps.

# fune: step validation.vin after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
the textbook VIN whose check digit is X (remainder ten) 1M8GDM9AXKP042788 → valid true, normalised 1M8GDM9AXKP042788, wmi 1M8, check digit matches true, reason —
a US-built Honda 1HGCM82633A004352 → valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason —
seventeen ones: the weights sum to 89, which leaves 1 11111111111111111 → valid true, normalised 11111111111111111, wmi 111, check digit matches true, reason —
a Mexican-built VIN, where the check digit is 0 3VWFE21C04M000001 → valid true, normalised 3VWFE21C04M000001, wmi 3VW, check digit matches true, reason —
lower case and spaces are normalised 1hgcm8263 3a004352 → valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason —
a North American VIN with the wrong check digit is invalid 1HGCM82643A004352 → valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit
a Mexican VIN with the wrong check digit is invalid too 3VWFE21C54M000001 → valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit
a single mistyped character elsewhere is caught by the check digit 1HGCM82633A004353 → valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit
a European VIN with Z in position 9 is valid but reports the mismatch WVWZZZ1JZXW000001 → valid true, normalised WVWZZZ1JZXW000001, wmi WVW, check digit matches false, reason —
an O where a 0 belongs is a forbidden letter 1HGCM82633A0O4352 → valid false, normalised —, wmi —, check digit matches —, reason forbidden-letter
Show the other 7 tests
CaseArgumentsExpected
a lower-case i is folded and still forbidden 1hgcm82633a0043i2 → valid false, normalised —, wmi —, check digit matches —, reason forbidden-letter
sixteen characters is too short 1HGCM82633A00435 → valid false, normalised —, wmi —, check digit matches —, reason bad-length
eighteen characters is too long 1HGCM82633A0043520 → valid false, normalised —, wmi —, check digit matches —, reason bad-length
a hyphen is a bad character, reported before the length 1HGCM826-33A004352 → valid false, normalised —, wmi —, check digit matches —, reason bad-character
the empty string → valid false, normalised —, wmi —, check digit matches —, reason empty
spaces only is empty → valid false, normalised —, wmi —, check digit matches —, reason empty
a non-string is empty, not an exception — → valid false, normalised —, wmi —, check digit matches —, reason empty

More from the author

THE CALCULATION: each character becomes a number (digits are themselves; A-H are 1-8, J-N 1-5, P 7, R 9, S-Z 2-9, skipping the forbidden letters), is multiplied by the weight for its position (8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2), and the sum is taken modulo 11; a remainder of 10 is written `X`. Position 9, the check digit itself, has weight 0.

FORBIDDEN LETTERS: I, O and Q never appear in a VIN because they read as 1, 0 and 0. They get their own reason, `forbidden-letter`, because they almost always mean a transcription slip that the user can fix by retyping that one character.

ACCEPTED INPUT: letters in either case (folded to upper case) and digits; ASCII spaces are ignored anywhere. Hyphens and anything else are refused, because VINs are never printed with them.

| reason | meaning | |--------------------|----------------------------------------------------------| | `empty` | nothing but spaces, or not a string at all | | `bad-character` | anything other than A-Z, a-z, 0-9 and spaces | | `bad-length` | not exactly 17 characters | | `forbidden-letter` | contains I, O or Q | | `bad-check-digit` | a North American VIN (starts 1-5) whose check digit is wrong |

The checks run in that order; the first failure is the reason. Validators answer rather than throw.

Out of scope: decoding the model year (position 10), the plant, or the manufacturer's name from the WMI, and pre-1981 VINs, which were not 17 characters.

Sources: ISO 3779:2009 "Road vehicles - Vehicle identification number (VIN) - Content and structure"; US 49 CFR 565.15 "Content requirements" (check digit, transliteration and weight tables), https://www.ecfr.gov/current/title-49/subtitle-B/chapter-V/part-565/subpart-B/section-565.15.

Files

PathBytes
README.md2,965
impl/python.py2,294
impl/rust.rs3,519
impl/typescript.ts2,429
vectors.json3,525