Extracting Structured Fields From a Vehicle Registration Document
10 min read · updated August 11, 2026
A vehicle registration card has perhaps fifteen fields on it and only one of them can be proved right or wrong without a second document. That field is the VIN, and the proof is arithmetic you can run in four lines of code. Everything else on the card is worth extracting; this is the field worth extracting correctly.
The VIN is the only field worth checking hard
A registration card is a short document. It carries a plate number, a VIN, a year/make/model, an owner name and address, an issue date, an expiry date, a registration class and a fee. A vision model will read most of that competently. The problem is not the average field; it is that the VIN is seventeen characters of unsegmented alphanumerics in a typeface chosen for density, frequently printed on a background pattern, and a single wrong character produces a value that looks entirely plausible and silently fails to join to anything else you hold about the vehicle.
The rest of the card degrades gracefully. A misread owner name is visible to a human. A misread expiry date is caught by a range check. A VIN with one transposed digit is caught by nothing at all unless you do the arithmetic — and because the VIN is the join key to the title, the insurance card, the inspection record and the recall database, a bad VIN does not fail loudly. It fails as an empty result set six weeks later.
The transliteration and weight table
For vehicles built for the North American market, position 9 of the VIN is a check digit, mandated by the US National Highway Traffic Safety Administration in 49 CFR Part 565. The scheme has two published tables. The first maps letters to numbers, because the checksum is arithmetic and the VIN is not:
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 digits 0-9 map to themselves I, O and Q are not used in a VIN at all
The letters I, O and Q are excluded from the alphabet precisely because they are confusable with 1, 0 and 0 — which is a design decision you can exploit. If your extraction returns an I, an O or a Q anywhere in a seventeen-character VIN, you do not need the checksum to know it is wrong; substitute the digit and re-check. That single rule catches a large share of OCR errors on this field before you compute anything.
The second table gives each of the seventeen positions a weight. Position 9 — the check digit itself — has weight zero, so it does not contribute to the sum that produces it:
position 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 weight 8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2
Multiply each transliterated value by its position weight, sum, take the remainder modulo 11. That remainder is the check digit, with the one special case that a remainder of 10 is written as the letter X.
A worked check digit, and a caught transposition
NHTSA’s own regulatory text uses 1M8GDM9AXKP042788 as its worked example, which makes it a safe VIN to test a validator against — it is a documented specimen rather than a real vehicle. Position 9 is X. Here is the sum:
pos char value weight product
1 1 1 8 8
2 M 4 7 28
3 8 8 6 48
4 G 7 5 35
5 D 4 4 16
6 M 4 3 12
7 9 9 2 18
8 A 1 10 10
9 X - 0 0 <- the check digit itself
10 K 2 9 18
11 P 7 8 56
12 0 0 7 0
13 4 4 6 24
14 2 2 5 10
15 7 7 4 28
16 8 8 3 24
17 8 8 2 16
-------
sum = 351
351 mod 11 = 10 -> check digit "X" matches position 9Now transpose two adjacent characters, which is the classic transcription error and the one a human proofreader misses most often. Swap positions 15 and 16, turning ...0427 88 into ...0428 78. The two products change from 28 and 24 to 32 and 21, the sum moves from 351 to 352, and 352 mod 11 is 0. The check digit would have to be 0; the document says X; the extraction is rejected. No second document was needed and no model was asked to be confident about anything.
def vin_check_digit(vin):
t = "0123456789.ABCDEFGH..JKLMN.P.RSTUVWXYZ"
values = {c: i % 10 if i < 10 else (i - 9) % 10 or 9
for i, c in enumerate(t) if c != "."}
weights = [8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2]
total = sum(VALUE[c] * w for c, w in zip(vin, weights))
r = total % 11
return "X" if r == 10 else str(r)Three errors the check digit cannot see
A passing check digit is evidence, not proof, and it is important to know exactly where the evidence stops.
- Letters that share a transliterated value. A and J are both 1. B, K and S are all 2. C, L and T are all 3. If the model reads a
Jwhere the document saysA, the sum is unchanged and the check digit still passes. This is the most common residual failure, because those letter pairs are also visually similar in condensed typefaces. - Swaps between positions ten apart. Look at the weight table: positions 1 and 11 both have weight 8, positions 2 and 12 both have weight 7, and so on down to 7 and 17. Exchanging two characters that sit ten positions apart leaves the weighted sum identical. It is a narrow hole, but it is a real one.
- Vehicles that never had a check digit. This is the failure that generates support tickets. The check digit is required by US regulation; the underlying international standard, ISO 3779, defines the VIN structure without mandating one. Vehicles built for markets outside North America routinely carry a position 9 that is simply another character of the vehicle descriptor section. Running the NHTSA formula over them produces a stream of false rejections on perfectly good documents.
The practical consequence is that the checksum result is a third value, not a boolean: pass, fail, or not_applicable. Decide which one applies from the World Manufacturer Identifier in positions 1 to 3 — the first character encodes the region — and record the outcome as a field alongside the VIN rather than throwing on failure. A failed checksum is a routing decision to human review, not an exception. That is the same tri-state discipline every checksum-validated identifier field needs, and it is worth reusing rather than reinventing.
Expiry, plate and the fields around the VIN
Position 10 of the VIN encodes the model year on a thirty-year cycle, and it is tempting to cross-check it against the year printed on the card. Do so carefully. The letters run A through Y with I, O, Q, U and Z excluded, then the digits 1 through 9, then back to letters — so a single code such as T is ambiguous between two years thirty years apart. NHTSA’s published rule resolves it using position 7: numeric there indicates the earlier cycle, alphabetic the later one. Look the year table up from NHTSA rather than embedding one you found in a blog post, and treat a mismatch between the VIN year and the printed year as a review flag rather than a rejection, because a card can legitimately record the year the vehicle was first registered.
The expiry date is the other field that repays care, for an entirely different reason: many states print it as a month and year only, and several print the month as a two-letter abbreviation in a stack next to a die-cut sticker. A schema that types this field as a full date forces the extractor to invent a day of month. Type it as a month-precision value with an explicit precision marker, which is what the general date field validation rule says about partial dates, and let the consumer decide whether “expires 09/2026” means the first or the last instant of September. Guessing at extraction time destroys information you cannot get back. Note also that the plate number is not a stable key — plates transfer between vehicles and are reissued — which is why the VIN, and not the plate, is what you join on when the title for the same vehicle arrives.