Randomly

Identifiers

VIN check digits and what NHTSA actually returns

Bipul Kumar 8 min
VIN check digits and what NHTSA actually returns
On this page 0%

I once imported a spreadsheet of “test VINs” from a dealer’s export and watched our decoder light up with real model years and real plant codes. The checksum passed. The NHTSA vPIC API returned a trim that a coworker recognized from a listing. Format-valid is not anonymous. A VIN is a public identifier for a physical object. Treat it like one.

I generate 17-character test VINs in the browser so forms can validate length, alphabet, and the ISO 3779 check digit without scraping a parking lot. The VIN generator and the VIN decoder are the pair. This post is the check digit, why I, O, and Q are missing, and when I actually call NHTSA versus when I only need a string that survives a regex.

Seventeen VIN character slots with position 9 marked as the check digit
Seventeen characters. Position 9 is the check. I, O, and Q never appear.

Seventeen characters and a picky alphabet

A VIN is 17 characters. No more, no less, for vehicles that follow the ISO 3779 / FMVSS 115 world most US forms assume. Letters A-Z and digits 0-9, except I, O, and Q. Those three look like 1 and 0 on a stamped plate. Transliteration tables for the check digit skip them on purpose. If your generator emits an O, you are generating a string that a lot of OEM docs would call invalid, and a lot of regexes will still accept because the author forgot.

WMI (world manufacturer identifier) is the first three. VDS (vehicle descriptor) is the next six, with the ninth character as check. VIS (vehicle identifier) is the last eight, where the tenth is often model year in the North American scheme and the eleventh a plant. I am painting with a broad brush. Motorcycle quirks and pre-1981 vehicles exist. If you are building a US insurance form in 2026, you are usually in the 17-character system.

Model year letters skip I, O, Q, U, Z, and 0. The year code repeats on a 30-year cycle. That is why a decoder needs a context year if you are looking at old metal. For test data, I care that position 10 is in the allowed year set, not that it matches a car in a lot.

The ISO 3779 check digit

Position 9 is the check. Each character has a transliteration value. Positions have weights: 8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2. Multiply, sum, mod 11. If the remainder is 10, the check character is X. Otherwise it is the digit of the remainder. Position 9’s own weight is 0, which is a polite way of saying the check does not include itself in the weighted sum.

I keep a fixed test vector. If I cannot get a known VIN from NHTSA examples or a well-documented sample to pass my function, I do not ship. Random round-trips (generate, then validate) will not catch a shared transliteration bug. Same lesson as IBAN mod-97 and ABA routing checksums: generator plus validator in one repo can congratulate themselves.

People implement the letter values wrong for S, T, U, V. Off-by-one tables compile. Tests with numeric-only VINs stay green. Put a K, an X check, and a year letter in your tests.

Weighted VIN positions with transliteration values and a running sum toward mod 11
Weights, transliteration, mod 11, X when the remainder is 10. That is the check.

Generator versus decoder

The generator’s job is a string that passes structural rules: length, alphabet, check digit, maybe a WMI from a small list so the first three are not nonsense. It will not invent a real vehicle. If vPIC returns a hit, you either picked a WMI that exists and a random rest that collided with a pattern, or you used a sample that was too realistic. I prefer WMIs that are reserved or obviously test-shaped when I can. When I cannot, I still label the fixture synthetic.

The decoder’s job on Randomly is to explain the parts we can explain locally: check digit validity, year code guesses, alphabet issues. NHTSA vPIC is the catalog of manufacturer meaning. That is a network call with an official database. I use vPIC when a human asked “what is this vehicle.” I do not use vPIC in a tight test loop for every generated VIN. You will annoy a public API and you will couple your CI to someone else’s uptime.

If you decode a generated VIN and vPIC says “Honda Accord,” do not put that VIN in a public screenshot next to a price. You may have landed on a pattern that points at a real vehicle class. You might even have collided with a VIN in the wild. 17 characters is not a UUID. The check digit eats one character. Manufacturer structure eats more. The random middle is smaller than people think.

NHTSA vPIC in practice

vPIC is the US-facing Decode VIN API. You send a VIN, you get attributes: make, model, body class, error codes if the VIN is malformed. It is the right tool for “user pasted a VIN on an insurance quote.” It is the wrong tool for “I need 500 rows in Postgres for a demo.” For the demo, generate. For the quote form’s happy path in staging, you can use a documented sample VIN from NHTSA’s own examples if they still publish one, and you accept that it maps to a real product line.

Rate limits and caching matter. Decode on blur, not on every keystroke. Cache by VIN. Do not log full VINs in third-party analytics if you can log a hash or the last four. A VIN is not a secret like a password, but it is a key to a vehicle history report. People sell those. See privacy for how we think about data that never needs to leave the browser on our tools. Your app is not our tools. You may have to send the VIN to vPIC. Send it. Do not also send it to a marketing pixel.

Error codes from vPIC are useful. “Check digit invalid” should match your local check. If they disagree, believe the spec and a known vector, then look at transliteration. I have seen APIs that are lenient on certain years. Lenient is not a spec.

VIN generator and decoder UI with a 17-character field and a check-digit valid badge
Generate structurally valid VINs locally. Call vPIC when you need manufacturer meaning.

Form validation I actually want

Length 17 after stripping spaces and hyphens. Some users paste with separators. Strip, then validate. Uppercase. Reject I, O, Q. Run the check digit. Then, if you are in the US quote flow, optionally vPIC. Do not regex ^[A-Z0-9]{17}$ and call it done. That regex allows I, O, Q. That regex allows a failing check. You will store junk and pay for it when the downstream vendor rejects the row a week later.

Do not silently replace O with 0. Show an error. Silent replacement looks helpful until a real VIN with a 0 becomes an O in a font and you “fix” it into a different vehicle.

For test fixtures, generate once, freeze the string, and commit it. Do not generate a new VIN on every test run if you assert on decoder output. vPIC results for a random VIN will move your snapshots. Local check-digit tests can be random. Snapshot tests should not be.

Adjacent identifiers

People mix VIN fields with serial numbers, stock numbers, and plate numbers. Plates have different alphabets per state. Stock numbers are dealer fiction. Keep the validator on the VIN field only. If you also need a US identity field for a driver, that is not a VIN. That is a name from a names generator and maybe an SSN-shaped value, format-only. I keep those in PII-safe test data on purpose. A VIN dump plus a name dump in the same CSV is a dossier.

If your form also takes a card for a deposit, Luhn is a different checksum. Do not write a generic “mod something” helper that tries to cover VIN, ABA, IBAN, and cards with a flag. I have inherited that helper. It was wrong in three of four modes.

One more practical note. When support asks for “the VIN,” they often mean last six. Last six are not unique worldwide. Do not key your database on last six. Store 17, display 17, search with a normalized compact form.

I keep a small notebook of VIN bugs that were not the check digit. A user pasted an HIN from a boat. A user pasted a serial from a bicycle. A user included the words “VIN:” in the field. Strip labels. Reject lengths other than 17 after strip. If you support off-road vehicles with different identifier rules, that is a different field, not a flag on the VIN parser.

Plant codes and model year codes are tempting to validate against a local table. Tables rot. vPIC is the living catalog for the US market. A local table is fine for “is this a letter in the year set,” not for “was this plant open in 2014.” If your product is not insurance or a recall tool, you might not need plant meaning at all. Length, alphabet, check digit. Ship that. Add vPIC when a human is about to buy a policy.

Frequently asked questions

Is a passing check digit a real vehicle?

No. It is a consistent 17-character VIN. vPIC may still return empty attributes or a generic WMI match.

Why are I, O, and Q missing?

They look like digits on plates and in type. The transliteration table is built without them. Do not put them back to be clever.

Should tests call NHTSA?

A few contract tests, maybe, with a fixture VIN and a timeout. Not every unit test. Localize the check digit. Mock vPIC at the HTTP boundary.

Can I use a VIN from a Craigslist ad in staging?

Please do not. Generate one. If you need a decoder screenshot, use a sample you are willing to see in a search index, or blur it.