Position 10 of a 17-character VIN encodes the model year. Many tutorials show a small lookup object covering 2010-2020 and stop there. That works until someone pastes a 1998 pickup or a 2032 EV into your form and your tool confidently prints the wrong year.
This post builds a model year mapper that is generated from rules instead of a hand-typed decade, and that returns every plausible year so the caller can choose with context.
The rules behind position 10
- The code alphabet is 30 characters long: the letters A-Y minus I, O, Q, U, and Z, followed by the digits 1-9.
- The sequence repeats every 30 years.
- The cycle we care about starts at A = 1980 and A = 2010 again; it will restart at A = 2040.
- The digit 0 is never a valid year code. Neither are I, O, Q, U, or Z.
Here is the full table for 1980-2039:
| Code | Years | Code | Years | Code | Years |
|---|---|---|---|---|---|
| A | 1980, 2010 | L | 1990, 2020 | W | 1998, 2028 |
| B | 1981, 2011 | M | 1991, 2021 | X | 1999, 2029 |
| C | 1982, 2012 | N | 1992, 2022 | Y | 2000, 2030 |
| D | 1983, 2013 | P | 1993, 2023 | 1 | 2001, 2031 |
| E | 1984, 2014 | R | 1994, 2024 | 2 | 2002, 2032 |
| F | 1985, 2015 | S | 1995, 2025 | 3 | 2003, 2033 |
| G | 1986, 2016 | T | 1996, 2026 | 4 | 2004, 2034 |
| H | 1987, 2017 | V | 1997, 2027 | 5 | 2005, 2035 |
| J | 1988, 2018 | 6 | 2006, 2036 | ||
| K | 1989, 2019 | 7 | 2007, 2037 | ||
| 8 | 2008, 2038 | ||||
| 9 | 2009, 2039 |
Typing that table by hand is how bugs creep in (skipping P, adding U). Let us generate it.
Generate the alphabet, not the table
const YEAR_ALPHABET = "ABCDEFGHJKLMNPRSTVWXY123456789";
const CYCLE = YEAR_ALPHABET.length; // 30
const EPOCH = 1980;
if (CYCLE !== 30) throw new Error("Year alphabet must have 30 codes");
export function candidateYears(
code: string,
minYear = EPOCH,
maxYear = new Date().getFullYear() + 1
): number[] {
const idx = YEAR_ALPHABET.indexOf(code.toUpperCase());
if (idx === -1) return [];
const out: number[] = [];
for (let y = EPOCH + idx; y <= maxYear; y += CYCLE) {
if (y >= minYear) out.push(y);
}
return out;
}
candidateYears("A") returns [1980, 2010] today and will automatically include 2040 once maxYear passes it. No edits required when the decade rolls.
The maxYear default of next calendar year reflects that manufacturers often release the next model year early. Adjust if your data source says otherwise.
Choosing between candidates
The year code alone cannot distinguish 1985 from 2015. Under North American rules, position 7 helps for passenger cars, light trucks, and MPVs: from model year 2010 on, a letter in position 7 usually means the newer cycle, while a digit there suggests the 1980-2009 cycle.
export function resolveModelYear(vin: string): number | null {
const v = vin.toUpperCase();
if (v.length !== 17) return null;
const years = candidateYears(v[9]);
if (years.length === 0) return null;
if (years.length === 1) return years[0];
const pos7 = v[6];
const newer = /[A-Z]/.test(pos7);
const filtered = years.filter((y) => (newer ? y >= 2010 : y < 2010));
return filtered.length ? filtered[filtered.length - 1] : years[years.length - 1];
}
This heuristic is not universal. Heavy trucks, buses, trailers, motorcycles, and non-North-American VINs may not follow it. For those, return all candidates and let the UI ask, or cross-check with a decoder such as NHTSA vPIC.
Return context, not just a number
A more honest API returns the candidates and how the choice was made:
type YearResult = {
code: string;
candidates: number[];
chosen: number | null;
method: "single" | "position7" | "latest" | "invalid";
};
export function decodeYear(vin: string): YearResult {
const v = vin.toUpperCase();
const code = v[9] ?? "";
const candidates = candidateYears(code);
if (v.length !== 17 || candidates.length === 0) {
return { code, candidates, chosen: null, method: "invalid" };
}
if (candidates.length === 1) {
return { code, candidates, chosen: candidates[0], method: "single" };
}
const newer = /[A-Z]/.test(v[6]);
const filtered = candidates.filter((y) => (newer ? y >= 2010 : y < 2010));
if (filtered.length) {
return { code, candidates, chosen: filtered[filtered.length - 1], method: "position7" };
}
return { code, candidates, chosen: candidates[candidates.length - 1], method: "latest" };
}
UIs can then show "Model year 2015 (could also be 1985)" when method is latest, which is far better than a silent guess.
Tests that catch the classic mistakes
import { describe, it, expect } from "vitest";
describe("candidateYears", () => {
it("never accepts I, O, Q, U, Z, or 0", () => {
for (const c of ["I", "O", "Q", "U", "Z", "0"]) {
expect(candidateYears(c)).toEqual([]);
}
});
it("maps the cycle boundaries", () => {
expect(candidateYears("A", 1980, 2039)).toEqual([1980, 2010]);
expect(candidateYears("Y", 1980, 2039)).toEqual([2000, 2030]);
expect(candidateYears("1", 1980, 2039)).toEqual([2001, 2031]);
expect(candidateYears("9", 1980, 2039)).toEqual([2009, 2039]);
});
it("rolls into the next cycle", () => {
expect(candidateYears("A", 1980, 2040)).toEqual([1980, 2010, 2040]);
});
it("is case-insensitive", () => {
expect(candidateYears("p", 1980, 2039)).toEqual([1993, 2023]);
});
});
Common pitfalls
- Treating model year as build date. The door jamb sticker shows the build month; position 10 is the manufacturer's model year. They can differ by a year.
-
Hard-coding
maxYear. Use the current date plus one so new model years appear automatically. - Dropping candidates. If your heuristic cannot decide, expose both years instead of guessing.
- Skipping validation. Check length, forbidden letters, and the check digit before trusting any position.
Wrap-up
Generating the year table from a 30-character alphabet keeps your decoder correct across cycles, makes the forbidden characters explicit, and removes a whole class of copy-paste bugs. Pair it with position 7 for a reasonable default, and surface ambiguity when it exists.
Disclosure: I work on VIN Lookup, a free VIN decoder that uses this kind of logic alongside official NHTSA data.
Top comments (0)