VIN Validation and Checksum Correction
A Vehicle Identification Number is the join key that ties an inspection to a physical unit, and when OCR reads a single character wrong — a letter O where a 0 belongs — the Driver Vehicle Inspection Report attaches to the wrong vehicle or to no vehicle at all. That silent break matters because 49 CFR § 396.11(a)(3) obligates the carrier to retain each report of defects and reconstruct, on demand, which unit the driver inspected. A defect or an out-of-service hold routed against a mistyped VIN is worse than a lost record: it certifies a repair on the wrong truck and leaves the actual defective unit dispatched. This guide specifies a deterministic VIN validator that confirms the ISO 3779 check digit, and a bounded correction pass that repairs the narrow class of OCR confusions it can prove — and routes everything ambiguous to human review rather than guessing.
The VIN check digit exists precisely so that a transcription error is detectable rather than silent. The seventeen characters carry a mod-11 checksum at position nine that a single wrong character almost always violates, so validation is not a formatting nicety — it is the cheapest possible integrity gate on the vehicle-to-report join. This validator is the strictest field check in the Automated Field Mapping & Data Normalization stage, and it consumes the confidence-scored strings that the DVIR Ingestion & Digital/Paper Parsing Workflows architecture produces upstream. Because it acts only where the checksum gives a provable answer, it complements the free-text repair described in Normalizing Inconsistent Driver Input Fields without borrowing its heuristics.
Prerequisites
Anchor link to "Prerequisites"This guide needs nothing beyond the standard library — VIN validation is pure arithmetic over a fixed transliteration table, and adding a dependency for it only widens the audit surface. Assume:
- Python 3.10+ — the code below uses
match/case, theX | Yunion syntax, andlist[...]generics. - A VIN string already extracted upstream. The character whitelist configured in Configuring Tesseract OCR for Fleet Inspection Forms already excludes
I,O, andQfrom the VIN region, so a well-configured OCR stage should never emit them — but paper transcription and third-party feeds still do, which is exactly why the validator treats their presence as a signal rather than trusting the input.
No network calls, no VIN-decode API, and no manufacturer database are required to validate the checksum. Those services answer what the VIN describes; this guide answers whether the string is internally consistent before you trust it as a join key.
Step-by-Step Implementation
Anchor link to "Step-by-Step Implementation"Step 1 — Validate character set and length
Anchor link to "Step 1 — Validate character set and length"A VIN is exactly seventeen uppercase characters, and the letters I, O, and Q are prohibited outright by the VIN content standard in 49 CFR Part 565 precisely because they are visually confused with 1 and 0. Reject any string that fails length or contains a prohibited letter — but record why it failed, because a prohibited letter is a strong OCR-error signal that Step 3 can often repair, whereas a wrong length usually is not.
from enum import StrEnum
VIN_LENGTH = 17
PROHIBITED = frozenset("IOQ") # invalid in a VIN — 49 CFR Part 565
# Transliteration values; I, O, Q are absent because they cannot appear.
TRANSLITERATION: dict[str, int] = {
**{str(d): d for d in range(10)},
"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,
}
class VinReject(StrEnum):
BAD_LENGTH = "bad_length" # not 17 chars — usually unrecoverable
PROHIBITED_LETTER = "prohibited" # contains I, O, or Q — OCR-error signal
BAD_CHARSET = "bad_charset" # a character outside the VIN alphabet
def charset_reason(vin: str) -> VinReject | None:
"""Return the reason a VIN fails structural checks, or None if it is clean."""
if len(vin) != VIN_LENGTH:
return VinReject.BAD_LENGTH
if any(c in PROHIBITED for c in vin):
return VinReject.PROHIBITED_LETTER
if any(c not in TRANSLITERATION for c in vin):
return VinReject.BAD_CHARSET
return None
Step 2 — Compute the ISO 3779 check digit
Anchor link to "Step 2 — Compute the ISO 3779 check digit"Transliterate each character to its numeric value, multiply by the position weight vector, sum the products, take the result modulo eleven, and render a remainder of ten as the literal X. The result must equal the character already sitting at position nine (index 8). The weight at position nine is 0, so the check digit never contributes to its own sum.
# Position weights 1..17; index 8 (position 9) is the check digit and weighs 0.
WEIGHTS: list[int] = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
def compute_check_digit(vin: str) -> str:
"""Compute the position-9 check digit for a 17-char VIN (ISO 3779)."""
total = sum(TRANSLITERATION[c] * w for c, w in zip(vin, WEIGHTS))
remainder = total % 11
return "X" if remainder == 10 else str(remainder)
def is_valid(vin: str) -> bool:
"""True only when the VIN is structurally clean and its check digit matches."""
if charset_reason(vin) is not None:
return False
return compute_check_digit(vin) == vin[8]
Worked example on 1HGCM82633A004352: the transliterated products sum to 311, and 311 % 11 == 3, which matches the 3 at index 8 — so the VIN is valid. A single wrong character shifts the sum off a multiple of eleven and the equality fails, which is what makes the checksum a reliable transcription-error detector.
Step 3 — Generate and prove OCR-confusion corrections
Anchor link to "Step 3 — Generate and prove OCR-confusion corrections"When a VIN fails, do not discard it — try to prove a repair. Build a map of the OCR confusions that actually occur on inspection scans, generate every candidate that substitutes those characters, and keep only candidates that both satisfy the check digit and contain no prohibited letter. Because I, O, and Q are invalid, their only admissible corrections are digits, which is why the map sends them to 0 and 1. Accept a correction only when exactly one candidate survives; anything else is ambiguous.
import itertools
from dataclasses import dataclass
# Directional OCR confusions seen on scanned DVIRs. Prohibited letters
# (I, O, Q) map only to digits, since a letter there is never admissible.
CONFUSIONS: dict[str, tuple[str, ...]] = {
"O": ("0",), "0": ("O",),
"I": ("1",), "1": ("I",),
"Q": ("0",),
"S": ("5",), "5": ("S",),
"B": ("8",), "8": ("B",),
}
@dataclass(frozen=True)
class VinResult:
status: str # "valid" | "corrected" | "review" | "rejected"
vin: str | None # the accepted or corrected VIN, when there is one
candidates: list[str] # every check-passing candidate considered
def _candidates(vin: str) -> set[str]:
"""All check-valid, charset-clean VINs reachable by OCR substitutions."""
swappable = [
(i, (vin[i], *CONFUSIONS.get(vin[i], ())))
for i in range(len(vin))
]
out: set[str] = set()
for combo in itertools.product(*(opts for _, opts in swappable)):
candidate = "".join(combo)
if charset_reason(candidate) is None and is_valid(candidate):
out.add(candidate)
return out
def validate_vin(vin: str) -> VinResult:
"""Validate a VIN and attempt a bounded, provable OCR correction."""
vin = vin.strip().upper()
match charset_reason(vin):
case None if is_valid(vin):
return VinResult("valid", vin, [vin])
case VinReject.BAD_LENGTH | VinReject.BAD_CHARSET:
return VinResult("rejected", None, []) # not an OCR-confusion case
case _:
passing = sorted(_candidates(vin))
if len(passing) == 1:
return VinResult("corrected", passing[0], passing)
return VinResult("review", None, passing) # 0 or >=2 — never guess
A record returning "review" must reach a human with the original raster crop attached, exactly as an OCR field below the confidence floor does. Once a VIN is accepted or corrected, the surrounding record still has to satisfy the canonical contract enforced across the Automated Field Mapping & Data Normalization stage before any defect it carries is scored.
Verification and Testing
Anchor link to "Verification and Testing"Validation is deterministic given a fixed transliteration table and weight vector, so assert on exact outputs. Three cases are non-negotiable: a known-good VIN passes untouched, a single OCR confusion is corrected to precisely one candidate, and a two-error input that yields more than one check-passing candidate is routed to review rather than auto-corrected.
import pytest
GOOD = "1HGCM82633A004352" # check digit computes to 3 and matches position 9
def test_known_good_vin_passes():
result = validate_vin(GOOD)
assert result.status == "valid"
assert result.vin == GOOD
def test_check_digit_is_three():
assert compute_check_digit(GOOD) == "3"
assert compute_check_digit(GOOD) == GOOD[8]
def test_single_ocr_error_is_corrected():
# Position 13 read as letter O instead of digit 0.
misread = "1HGCM82633A0O4352"
result = validate_vin(misread)
assert result.status == "corrected"
assert result.vin == GOOD
assert result.candidates == [GOOD]
def test_two_errors_route_to_review():
# Two confusable characters produce more than one check-passing candidate.
ambiguous = "8OGCM82633A004352"
result = validate_vin(ambiguous)
assert result.status == "review"
assert result.vin is None
assert len(result.candidates) >= 2 # ambiguous — a human must decide
def test_wrong_length_is_rejected_not_corrected():
result = validate_vin("1HGCM82633A00435") # 16 chars
assert result.status == "rejected"
The test_two_errors_route_to_review fixture is the important one: 8OGCM82633A004352 resolves to two distinct check-valid candidates once the O/0 and B/8 confusions are expanded, so the validator refuses to pick one. Auto-correcting an ambiguous VIN would fabricate a join key and destroy the audit defensibility the checksum was there to protect.
Common Failure Modes and Gotchas
Anchor link to "Common Failure Modes and Gotchas"I,O, andQare invalid — treat them as evidence. They are prohibited by 49 CFR Part 565, so their appearance in an extracted VIN is not a formatting quirk; it is near-certain proof of an OCR confusion with1or0. The correction pass exploits exactly this, mapping each prohibited letter only to its digit. Never widen the charset to “accept” them — that discards the strongest signal you have.- Pre-1981 VINs are not 17 characters and carry no standardized check digit. Vehicles built before the 1981 ISO 3779 standardization use variable-length identifiers with no position-9 checksum. Do not force-validate them: detect the short length, mark the VIN as
legacy_unverifiable, and route it for manual confirmation instead of rejecting a legitimate older unit or, worse, “correcting” it into a plausible-looking modern VIN. - Ambiguous multi-candidate corrections must never auto-pick. When two or more substituted candidates satisfy the check digit, any choice is a guess. Route the record to human review with every candidate listed. Picking the “most likely” one attaches a defect to a vehicle you cannot prove was inspected, which is the precise failure § 396.11(a)(3) retention exists to prevent.
- Uppercase and trim before anything else. A lowercase
oor a trailing space silently changes both the length check and the transliteration lookup. Normalize first, then validate, so a whitespace artifact never masquerades as a checksum failure.
Frequently Asked Questions
Anchor link to "Frequently Asked Questions"Why validate the VIN check digit instead of just trusting the OCR confidence score?
Confidence measures how sure the engine is about the pixels; the check digit measures whether the seventeen characters are internally consistent. They catch different failures. A VIN can be read at high confidence and still be wrong by one character, and the mod-11 checksum detects almost every single-character error regardless of how confident the engine was. Run both gates: admit only a VIN that clears the confidence floor and satisfies its check digit.
Can the corrector fix every OCR misread automatically?
No — and it should not try. It repairs only the narrow, provable case where substituting known OCR confusions produces exactly one VIN that satisfies the check digit and contains no prohibited letter. When zero candidates pass, the error is outside the confusion map or there are too many wrong characters; when more than one passes, the correct VIN is genuinely ambiguous. Both route to human review, because auto-correcting either would fabricate a join key.
What should happen to a VIN that fails on a pre-1981 vehicle?
Detect it by its non-standard length and mark it unverifiable rather than rejecting it. Pre-1981 identifiers predate ISO 3779 and have no position-9 check digit, so the checksum cannot apply. Forcing the 17-character algorithm on them either discards a legitimate record or invents a correction, so hand these to a reviewer who can confirm the unit against the fleet roster.
Related
Anchor link to "Related"- Automated Field Mapping & Data Normalization — the parent stage whose canonical contract the validated VIN must satisfy.
- Normalizing Inconsistent Driver Input Fields — the free-text repair pass this checksum-driven validator sits beside.
- Configuring Tesseract OCR for Fleet Inspection Forms — the OCR stage whose VIN whitelist excludes the prohibited letters this validator flags.
- PDF & Image OCR Pipeline Setup — the upstream pipeline that produces the confidence-scored VIN strings.
Part of the Automated Field Mapping & Data Normalization guide, within the DVIR Ingestion & Digital/Paper Parsing Workflows architecture. Back to DVIR Ingestion & Digital/Paper Parsing Workflows.