VIN Format Rules (ISO 3779)
A VIN is exactly 17 characters on every car, truck, and motorcycle produced worldwide since 1981. The standard (ISO 3779, adopted by the US NHTSA as FMVSS 115) mandates:
- Length: exactly 17 characters, no spaces, no hyphens
- Characters: uppercase letters and digits only
- Excluded letters: I, O, Q (confusable with 1 and 0)
- Position 9: check digit (0-9 or X), computed from the other 16 characters
The character class [A-HJ-NPR-Z0-9] encodes exactly these rules:A-H (skip I), J-N (skip O), P (skip Q),R-Z, and 0-9.
The Three Sections of a VIN
| Positions | Section | Meaning |
|---|---|---|
| 1-3 | WMI | World Manufacturer Identifier (1HG = Honda USA) |
| 4-8 | VDS | Vehicle Descriptor (model, body, engine) |
| 9 | Check digit | 0-9 or X — computed from positions 1-8 + 10-17 |
| 10 | Model year | A=1980/2010, B=1981/2011, ... (30-year cycle) |
| 11 | Plant | Manufacturing plant code |
| 12-17 | Serial | Sequential production number |
Language-Specific Usage
JavaScript / TypeScript
const VIN = /^[A-HJ-NPR-Z0-9]{17}$/;
function isValidVIN(input) {
if (typeof input !== 'string') return false;
return VIN.test(input.toUpperCase());
}
isValidVIN("1HGCM82633A004352"); // true (2003 Honda Accord)
isValidVIN("WVWZZZ1KZAM091543"); // true (2010 VW Golf)
isValidVIN("ABCDEFGHIJKLMNOPQ"); // false (contains I, O, Q)
isValidVIN("1HG"); // false (too short)
// Full validator with check-digit math
function fullValidateVIN(vin) {
vin = String(vin).toUpperCase();
if (!VIN.test(vin)) return false;
const weights = [8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2];
const translit = {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};
const value = (c) => translit[c] ?? parseInt(c, 10);
const sum = [...vin].reduce((s, c, i) => s + value(c) * weights[i], 0);
const expected = sum % 11;
return vin[8] === (expected === 10 ? 'X' : String(expected));
}
fullValidateVIN("1HGCM82633A004352"); // true
fullValidateVIN("1HGCM82634A004352"); // false (digit 9 wrong)Python
import re
VIN_RE = re.compile(r'^[A-HJ-NPR-Z0-9]{17}$')
def is_valid_vin(s: str) -> bool:
if not isinstance(s, str):
return False
return bool(VIN_RE.match(s.upper()))
# Full validator with ISO 3779 check-digit
WEIGHTS = [8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2]
TRANSLIT = {
'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,
}
def full_validate_vin(vin: str) -> bool:
vin = str(vin).upper()
if not VIN_RE.match(vin):
return False
total = sum(
(TRANSLIT.get(c) or int(c)) * w
for c, w in zip(vin, WEIGHTS)
)
expected = total % 11
return vin[8] == ('X' if expected == 10 else str(expected))
assert full_validate_vin("1HGCM82633A004352")
assert not full_validate_vin("1HGCM82634A004352")Go
package main
import (
"fmt"
"regexp"
"strings"
)
var vinRe = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`)
func isValidVIN(s string) bool {
return vinRe.MatchString(strings.ToUpper(s))
}
func main() {
fmt.Println(isValidVIN("1HGCM82633A004352")) // true
fmt.Println(isValidVIN("ABCDEFGHIJKLMNOPQ")) // false
}PHP
<?php
function is_valid_vin(string $s): bool {
return (bool) preg_match('/^[A-HJ-NPR-Z0-9]{17}$/', strtoupper($s));
}
var_dump(is_valid_vin("1HGCM82633A004352")); // true
var_dump(is_valid_vin("1HG")); // false
// Extract all VINs from a bulk document
preg_match_all('/\b[A-HJ-NPR-Z0-9]{17}\b/', strtoupper($doc), $m);
print_r($m[0]);Common Pitfalls
Using [A-Z0-9] instead of [A-HJ-NPR-Z0-9]
/^[A-Z0-9]{17}$/ looks correct but silently accepts I, O, and Q — which are illegal in a real VIN. When users hand-enter a VIN from a title or inspection sheet, they often misread 1 as I and 0 as O. The stricter class catches these errors at input time; the looser class corrupts your database with VINs that cannot be decoded by any downstream service (NHTSA, Carfax, etc.).
Case sensitivity
VINs are canonically uppercase. If you don't normalise user input before checking,"1hgcm82633a004352"fails the regex even though it's a valid VIN with wrong case. Always .toUpperCase() (or equivalent) before matching.
Expecting the regex to detect fakes
A VIN that matches the format can still be fake — someone could type any 17 characters from the allowed set. The regex guarantees structural validity. Forsemanticvalidity (real vehicle in a real database) you need to call a service like NHTSA's vPIC API (vpic.nhtsa.dot.gov/api/) which returns make, model, year, and verified fields. Regex is the fast first-pass filter; the API is the source of truth.
Length miscounts
Spaces or hyphens creep in when a user copies a VIN from a printed document. Strip them before matching: vin.replace(/[\s-]+/g, ""). Otherwise"1HG CM826 33A 004352" (with spaces that happen to total 17 non-space characters) still fails because the overall string is longer than 17.
Position-Specific Patterns
| Goal | Pattern | Returns |
|---|---|---|
| Any valid VIN | /^[A-HJ-NPR-Z0-9]{17}$/ | boolean match |
| Check-digit shape | /^[A-HJ-NPR-Z0-9]{8}[0-9X][A-HJ-NPR-Z0-9]{8}$/ | rejects impossible 9th char |
| WMI only (first 3) | /^([A-HJ-NPR-Z0-9]{3})/ | group 1 = manufacturer |
| Model year (position 10) | /^.{9}([ABCDEFGHJKLMNPRSTVWXY123456789])/ | group 1 = year letter/digit |
| Extract from text | /\b[A-HJ-NPR-Z0-9]{17}\b/g | array of VINs |
Testing Your VIN Regex
Use the live Regex Tester with this canonical test block:
Valid VINs:
1HGCM82633A004352 (2003 Honda Accord)
WVWZZZ1KZAM091543 (2010 VW Golf)
JH4KA7561PC008269 (1993 Acura Legend)
5YJ3E1EA5KF123456 (2019 Tesla Model 3)
Invalid examples:
ABCDEFGHIJKLMNOPQ (contains I, O, Q)
1HGCM82633A00435 (16 chars — too short)
1HGCM82633A0043522 (18 chars — too long)
1hgcm82633a004352 (lowercase)
1HGCM82633A 004352 (contains space)
Pattern: /^[A-HJ-NPR-Z0-9]{17}$/
Expected matches: first 4 lines only.Performance Notes
Anchored fixed-length regex is as fast as any string check — single-pass, no backtracking. Running the pattern over a million-row CSV takes under a second on a laptop. The real cost of VIN validation is the check-digit math; even that is simple arithmetic. For high-volume ETL pipelines, batch-apply the regex first to drop malformed rows, then run the check-digit calculation only on the surviving rows.