What does the date code on a car battery mean? On most brands it's a letter for the month and a digit for the year, so C3 means March 2023. That's Interstate's own example, and it sounds like a five-minute regex.
Then you read the same code across brands. K5 is November 2025 on an Interstate. On a Deka it's October 2025. On a NorthStar it's September 2025.
Same two characters, three different months, and that's before you get to the missing decade.
I built a battery date code decoder that covers 13 brands, 10 of them with a rule from the maker. This post covers the parsing problems I hit, and how the decoder handles a code it can't settle. The rules are open data, so you can use them yourself.
Trap 1: the month letters aren't one alphabet
There are three keys in use, and the difference comes from skipped letters. Deka's key leaves out I. NorthStar's leaves out E and I.
{
"al": "ABCDEFGHIJKL",
"noi": "ABCDEFGHJKLM",
"noei": "ABCDFGHJKLMN"
}
So a month isn't a letter minus 64. It's a position in a key, and the key belongs to the brand:
month = data["keys"][rule["key"]].find(letter) + 1
If a letter isn't in the key, find gives you 0, which means no reading. That matters: I is a real month on an Interstate and not a month at all on a Deka.
The practical result is that if you don't know the brand, you can't give one answer. You have to give several.
Trap 2: the decade is missing
C3 holds one year digit. March 2023 and March 2013 are both valid, and so is March 2033 if you're careless.
The decoder picks the latest year that ends in that digit and isn't in the future:
function lastDigit(d, m, now) { // the latest year ending in d that isn't ahead of this month
var y = now.y - (((now.y - d) % 10) + 10) % 10;
return (y === now.y && m > now.m) ? y - 10 : y;
}
The month check is the part that's easy to forget. In November 2026, C6 is March 2026. In February 2026, the same code can't be March 2026 yet, so it has to be March 2016.
The older reading never goes away, though. The result always says "or March 2013", because a battery that old can still be sitting in a car. One optional input can settle it: a label can't be older than the © year printed on it. That's our reasoning, not a maker's rule, and the page says so.
Trap 3: some codes aren't letters at all
The 13 brands use several formats, and each gets its own small reader:
| Format | Brand | Example | Means |
|---|---|---|---|
| Letter, digit | Interstate | C3 |
March 2023 |
| Series letter, month, year digit, plant | ACDelco | P094N |
September 2014 |
| Year digit, day of the year | OPTIMA | 6086 |
March 27, 2026 |
| Month number, year digit | Motorcraft | 3-2 |
March 2022 |
| Sticker | Deka | 3/23 |
March 2023 |
ODYSSEY adds a 12-digit code that ends in the month and year, and U.S. Battery's AGM line uses DDMMYY or YYMMDD. A table of small functions, keyed by a short format name, kept this tidy. Each brand then lists which readers apply to it, and adding a brand is a data change:
var READ = {
ld: function (c, r, keys, now) { // letter, digit, then anything: a longer stamped code starts this way
var x = c.match(/^([A-Z])-?([0-9])/);
if (!x) return null;
var m = keys[r.key].indexOf(x[1]) + 1;
if (!m) return null;
return { y: lastDigit(+x[2], m, now), m: m, decade: true };
},
nd: function (c, r, keys, now) { // month number, then one year digit: Ford's 3-2
var x = c.match(/^([0-9]{1,2})[\/-]([0-9])$/);
return x && +x[1] >= 1 && +x[1] <= 12
? { y: lastDigit(+x[2], +x[1], now), m: +x[1], decade: true } : null;
}
};
Trap 4: leap years move a date by a day
OPTIMA's format is a year digit and a day number, so 6086 is day 86 of a year ending in 6. Ten years back is not the same date:
- Day 86 of 2026 is March 27.
- Day 86 of 2016 is March 26, because 2016 was a leap year.
If you show "or ten years earlier", you can't just subtract 10 from the year. Recalculate from the day number:
function dayOf(y, n) { // day n of year y as a month and day
var t = new Date(Date.UTC(y, 0, n));
return t.getUTCFullYear() === y ? { m: t.getUTCMonth() + 1, d: t.getUTCDate() } : null;
}
Date.UTC(y, 0, n) lets January overflow into the right month for you, and the year check rejects day 366 in an ordinary year.
Trap 5: four digits can be two different things
With no brand picked, 1224 matches two formats:
- December 2024, as month and year. That rule is only Reported, for Duralast and Duracell.
- August 12, 2021, as an OPTIMA serial: year digit 1, day 224.
Neither reading can be ruled out, so the decoder shows both. And 2026 typed on its own looks like a four-digit code, but it's far more likely someone typing the year, so a bare year doesn't get read as an OPTIMA serial unless OPTIMA is picked.
The sort order is a product decision too. Rules from a maker usually outrank rules from a seller's guide, but a four-digit code with no brand gets the month-and-year reading first, with the OPTIMA serial after it.
Trap 6: people type what they see
Battery stamps are worn, and O and 0 look alike. If a code gives no reading and it contains an O, the decoder tries again with zeros:
if (!r.readings.length && !r.recall && c.indexOf('O') >= 0) { // the letter O typed for a zero: KO for K0
var z = pass(c.replace(/O/g, '0'), brand, data, now);
if (z.readings.length) { z.typed = c; return z; }
}
It records what you typed, so the result can show the swap instead of hiding it. The retry only runs after a clean read fails, so it can't change a code that already works.
Trap 7: what the date marks
Even a correct decode doesn't tell you the battery's birthday. Interstate's own wording says the code shows when the battery "shipped from the manufacturing plant". ACDelco's marks when it was made. For a warranty argument, that gap can matter, so each brand carries a marks field and the result says which one it is.
Sources get a tier, and some brands get no answer
This was the most useful design decision. Every rule has a tier:
{ "t": "ld", "key": "al", "tier": "maker" }
Ten brands have a rule from the maker. Duralast, EverStart and Duracell have only what we found in sellers' guides or how-to articles, so those results are marked Reported. A rule from the brand's own warranty sheet and a rule from a blog aren't the same strength.
Seven brands get no decode at all. Autocraft, MotoMaster, Super Start, NAPA and DieHard have no rule from any source we'd stand behind. For Varta and Exide, guides disagree with each other. A confident wrong date is worse than "we don't know", so the decoder says that instead. For the five with no rule, a user can still borrow the common pattern, and the result is marked as a guess.
Testing it
Every maker's printed example runs through the decoder in test_battery_code.mjs, and the build fails if any of them gives the wrong month. The suite has 60 checks, including the traps above.
Pin now in your tests. The whole decoder depends on the current date, and a test that says "C6 means March 2026" will start lying the moment the decade turns:
const now = { y: 2026, m: 11, d: 7 };
There's one bug I haven't fixed. Battery group sizes such as H6 also fit the letter-and-digit pattern, so typing one reads as August 2026 on most brands. Nothing in a two-character string tells them apart, and I haven't found a source to build the warning on yet.
Use the data
The rules are open under CC BY 4.0 at /data/battery-date-codes.json, with CORS on. Here's a complete reader for the letter-and-digit format, which is the same logic in Python:
#!/usr/bin/env python3
"""Read a letter-and-digit battery date code with the open Who Actually Makes dataset. Standard library only."""
import datetime, json, sys, urllib.request
URL = "https://whoactuallymakes.com/data/battery-date-codes.json"
req = urllib.request.Request(URL, headers={"User-Agent": "battery-date-example/1.0"})
data = json.load(urllib.request.urlopen(req, timeout=30))
def last_digit_year(digit, month, today):
"""The latest year ending in this digit that isn't ahead of the current month."""
year = today.year - ((today.year - digit) % 10)
if year == today.year and month > today.month:
year -= 10
return year
def read(code, today):
letter, digit = code[0].upper(), int(code[1])
for brand in data["brands"]:
for rule in brand.get("rules", []):
if rule["t"] != "ld":
continue
month = data["keys"][rule["key"]].find(letter) + 1
if month:
who = "maker" if rule["tier"] == "maker" else "reported"
year = last_digit_year(digit, month, today)
yield f"{datetime.date(year, month, 1):%B %Y}", brand["name"], who
today = datetime.date(2026, 11, 7)
for reading, brand, who in read(sys.argv[1] if len(sys.argv) > 1 else "K5", today):
print(f"{reading:16} {brand:22} {who}")
Running it for K5 prints:
The dataset is listed with the others on our open data page, and how we know explains how sources get graded. Every maker rule on the page links to the record it came from, with the date it was read.
Limits
- A maker can change its format, and the rules only cover what we could read.
- A date code doesn't show how a battery was stored or how much life it has left.
- Some maker sources are old, and each rule on the page shows its date.
AI assistants helped read the records and draft this post, as they do on the site. The examples were run on 7 November 2026.
Top comments (0)