🍱 Lunchbox Hands

dates

YYYY vs yyyy: The Week-Year Bug That Ships in December

YYYY vs yyyy: in date-fns, Java and ICU, YYYY is the week-based year, not the calendar year — it prints 2027-12-31 for Dec 31, 2026. Plus a token cheat sheet.

Your date formatting has been correct for eleven months, and then on the last day of December a nightly job writes export-2027-12-31.csv. Nothing changed, no deploy went out, and the date is off by exactly one year in exactly one component. YYYY is not the year. In the Unicode date format spec that Java, ICU, Swift, Android and date-fns all implement, YYYY is the week-based year, and it agrees with the calendar year every day except a handful at the seam between December and January — which is precisely why the bug passes code review, passes tests, and detonates during the holidays. Here’s the mechanism, the two tokens that cause it, and a cross-library cheat sheet so you can check the format strings you already shipped. If you have a suspicious date in front of you right now, paste it into the Date Format Converter and see what instant it actually represents.

There are two different years

ISO 8601 defines a week-based calendar alongside the ordinary one. Weeks start on Monday, and week 1 is the week containing the first Thursday of January (equivalently: the week containing January 4). A week belongs entirely to one year, so the last days of December can fall inside week 1 of next year, and the first days of January can fall inside week 52 or 53 of last year.

The week-based year is the year of the week, not the year of the date. Those are the same number about 360 days out of 365:

DateWeekdayCalendar yearISO weekISO week-yearLocale week-year (en-US)
2014-12-29Monday20142015-W0120152015
2017-01-01Sunday20172016-W5220162017
2026-12-31Thursday20262026-W5320262027
2027-01-01Friday20272026-W5320262027

The last column is the part most people miss: there are two week-year flavors. ISO’s rule (Monday start, week 1 contains Jan 4) is one; every locale gets its own (en-US starts weeks on Sunday and puts Jan 1 in week 1). The Unicode token Y means the locale week-year, which is why the numbers in the last two columns disagree on two of the four rows above.

What it looks like in code

date-fns implements Unicode tokens, so the two spellings produce two different dates:

import { format } from 'date-fns'; // 4.4.0
const d = new Date('2026-12-31T12:00:00Z');

format(d, 'yyyy-MM-dd');  // '2026-12-31'  ← calendar year
format(d, 'RRRR-MM-dd');  // '2026-12-31'  ← ISO week-year
format(d, 'YYYY-MM-dd', { useAdditionalWeekYearTokens: true });
                          // '2027-12-31'  ← locale week-year

That third line is the bug in its purest form: a real-looking date, correct month and day, stamped a year into the future. Without the opt-in option, date-fns refuses outright:

RangeError: Use `yyyy` instead of `YYYY` (in `YYYY-MM-dd`) for formatting years
to the input `Thu Dec 31 2026 ...`; see:
https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md

That error exists because of where the wrong token usually comes from.

Moment has its own dialect, and everyone copies it

Moment.js never followed Unicode. In Moment (and Day.js, which copied its token set), YYYY is the calendar year — GGGG is the ISO week-year and gggg is the locale one:

moment('2026-12-31T12:00:00Z').format('YYYY-MM-DD'); // '2026-12-31'
moment('2026-12-31T12:00:00Z').format('GGGG');       // '2026'
moment('2026-12-31T12:00:00Z').format('gggg');       // '2027'

So a pattern string that was correct in a Moment codebase, or in the Stack Overflow answer you copied it from, silently changes meaning the moment it lands in date-fns, Luxon, Java, or a Swift DateFormatter. That is the entire failure mode: not a typo, a dialect change. It’s also why date-fns chose to throw rather than quietly format the week-year, and why the option that re-enables it is named useAdditionalWeekYearTokens.

Two library-specific notes worth knowing:

  • Luxon doesn’t use Y at all. Its ISO week-year token is kkkk, and unknown letters pass through as literal text — DateTime.toFormat('YYYY') returns the string "YYYY", not a number. Silent, but at least obviously wrong.
  • Java splits the year three ways: u is the proleptic year, y is the year-of-era, and Y is the week-based-year. And Y, like w and W, is documented as a “localized WeekFields element” — so Java’s Y isn’t ISO either, it follows the formatter’s locale. The classic version of this bug is new SimpleDateFormat("YYYY-MM-dd").

The best-known production casualty was December 29, 2014 — a Monday, and therefore ISO week 1 of 2015. Twitter for Android locked users out for several hours and TweetDeck displayed every incoming tweet as roughly 365 days old (Engadget, SiliconANGLE). Twitter only ever described it as “a bug in our front end code,” but the date — and the exactly-one-year offset — is the week-year signature.

DD is the second trap

The same dialect split hits day tokens, with a much weirder output. In Unicode/LDML, d is day of month and D is day of year. In Moment, D and DD are day of month. So the habitual YYYY-MM-DD does two wrong things at once when pasted into a Unicode formatter: on 2026-12-31 it would render 2027-12-365.

date-fns guards this one too, with its own message:

RangeError: Use `dd` instead of `DD` (in `yyyy-MM-DD`) for formatting days of
the month to the input `Thu Dec 31 2026 ...`; see:
https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md

It even flags DDD — the legitimate 3-digit day-of-year token — on the assumption you meant ddd, the abbreviated weekday. The escape hatch there is useAdditionalDayOfYearTokens. If you ever intentionally opt into either option, leave a comment saying why, because the next reader will assume it’s a mistake.

The cheat sheet

What you wantstrftimeMoment / Day.jsLDML (date-fns, Java, ICU, Swift)Intl.DateTimeFormat
Calendar year%YYYYYyyyy (Java also uuuu)year: 'numeric'
ISO week-year%GGGGGRRRR (date-fns)—
Locale week-year—ggggYYYY—
Month, 01–12%mMMMMmonth: '2-digit'
Day of month%dDDddday: '2-digit'
Day of year%jDDDDDDD—
ISO week number%VWW (ww = locale)II (ww = locale)—
Hour, 00–23%HHHHHhour + hourCycle: 'h23'
Hour, 01–12%Ihhhhhour + hour12: true
Minute%Mmmmmminute: '2-digit'
Second%Ssssssecond: '2-digit'
Sub-second%f (Python, µs)SSSSSSfractionalSecondDigits: 3
UTC offset%zZ → +00:00, ZZ → +0000XXX / xxx (Java Z)timeZoneName: 'longOffset'
Zone name%Zz (needs moment-timezone)zzz (Java VV = zone ID)timeZoneName: 'short'

Two things that table is quietly telling you. First, Intl.DateTimeFormat has no token language at all — it takes an options object, and there is no week-year option, so the entire class of bug is unreachable from it. Second, Go’s reference-time layouts ("2006-01-02") have nothing to confuse either. The pattern-string libraries are the ones carrying two incompatible dialects of the same letters.

What to do

Store instants, format only at the edge. A timestamp that lives in your database as an ISO 8601 string or epoch seconds cannot be corrupted by a format token, because no tokens were involved — toISOString() always emits UTC in a fixed shape. Formatting is a display concern, and the fewer places it happens, the fewer places YYYY can hide. The store-UTC-display-local rule covers where that breaks down (future events pinned to a wall clock), and Unix timestamps explained covers the seconds-vs-milliseconds side of the same discipline.

Grep your repo for capital Y and bare DD inside format strings, then check which library each call site belongs to. A YYYY in a Moment call is fine; the same string in date-fns, Luxon, DateTimeFormatter, or a Swift DateFormatter is a defect waiting for December. Filenames, report headers, cache keys and export paths are the usual victims, because nobody writes assertions about them.

Add a test that formats 2026-12-31 and 2027-01-01. Those two dates catch both directions — a December day pulled forward and a January day pushed back — and they cost nothing to run the other 363 days of the year. If the date feeds a scheduled job, confirm what your schedule actually fires with the cron parser, since a year-end job that runs at an unexpected local hour will land on a different side of the boundary than you assumed.

When a date looks wrong, resolve it to an instant before theorizing. Paste the string into the Date Format Converter and it shows you ISO 8601, Unix seconds and milliseconds, RFC 2822/UTC, your local time, and a relative row that reads the gap in plain English (“next year”, “yesterday”) — all in the browser, with no format string of your own to get wrong. If 2027-12-31 comes back as a genuine instant a year out, the value is bad, not the display. For epoch values specifically the Timestamp Converter is the faster path, and if the discrepancy turns out to be offset-shaped rather than year-shaped, the Timezone Converter will show you which zone produced it.

One last parsing gotcha while you’re debugging, because it produces a similar off-by-one: in JavaScript, a date-only string is parsed as UTC midnight, but a date-time string with no offset is parsed as local time. new Date('2026-12-31') and new Date('2026-12-31T00:00:00') are different instants on every machine that isn’t running UTC — and if either one then passes through a spreadsheet on its way to a report, Excel has its own ways of rewriting dates before anyone sees them.