Why does YYYY give the wrong year?
Date Format Patterns Across Languages
YYYY gives the wrong year because in Java, date-fns, ICU and every other library that follows the Unicode LDML pattern syntax, uppercase Y is the week-numbering year, not the calendar year. The two agree for most of the year and differ only in the last days of December or the first days of January, when a week straddles the new year. So a pattern like YYYY-MM-dd passes every test written in March and then prints 2027-12-31 on 31 December 2026. The calendar year is lowercase yyyy (or uuuu in Java). The same trap exists for DD, which is day of year rather than day of month, and for mm, which is minutes rather than months. The fix is to learn which of the pattern families your language uses, because they assign different meanings to the same letters.
Four pattern families
Almost every date API uses one of four syntaxes:
- LDML letters (Unicode Locale Data Markup Language): Java's
DateTimeFormatter, Kotlin, Swift, ICU, date-fns, Luxon, and the Date Formatter on this site. Case matters for every letter. - Moment-style letters: Moment.js and Day.js. Similar-looking, but
YYYYandDDmean what most people expect, which makes code moving between the two families dangerous. strftimepercent codes: C, Python, Ruby, PHP'sdatealternatives, shelldate, Go'sstrftimeports.- Database-specific: PostgreSQL and Oracle's
to_charpicture strings, MySQL'sDATE_FORMATpercent codes, which are close tostrftimebut not identical.
JavaScript itself has no pattern-based formatter. Intl.DateTimeFormat takes options ({ year: 'numeric', month: '2-digit' }) rather than a pattern, and toISOString() has one fixed format.
The token table
Each row is one field, printed for Saturday 26 September 2026 at 14:05:09.123 in India (+05:30).
| Field | Example | LDML (Java, date-fns) | Moment.js | Python strftime |
PostgreSQL to_char |
MySQL DATE_FORMAT |
|---|---|---|---|---|---|---|
| Calendar year | 2026 | yyyy (Java also uuuu) |
YYYY |
%Y |
YYYY |
%Y |
| Two-digit year | 26 | yy |
YY |
%y |
YY |
%y |
| ISO week-numbering year | 2026 | RRRR in date-fns; YYYY in Java with an ISO locale |
GGGG |
%G |
IYYY |
%x |
| ISO week | 39 | II in date-fns; ww in Java with an ISO locale |
WW |
%V |
IW |
%v |
| Month, padded | 09 | MM |
MM |
%m |
MM |
%m |
| Month, unpadded | 9 | M |
M |
%-m (glibc, macOS) |
FMMM |
%c |
| Month name, short | Sep | MMM |
MMM |
%b |
Mon |
%b |
| Month name, full | September | MMMM |
MMMM |
%B |
FMMonth |
%M |
| Day of month, padded | 26 | dd |
DD |
%d |
DD |
%d |
| Day of month, unpadded | 26 | d |
D |
%-d |
FMDD |
%e |
| Day of year | 269 | D / DDD |
DDDD |
%j |
DDD |
%j |
| Weekday, short | Sat | EEE |
ddd |
%a |
Dy |
%a |
| Weekday, full | Saturday | EEEE |
dddd |
%A |
FMDay |
%W |
| Hour, 00–23 | 14 | HH |
HH |
%H |
HH24 |
%H |
| Hour, 01–12 | 02 | hh |
hh |
%I |
HH12 (or HH) |
%h or %I |
| AM/PM | PM | a |
A |
%p |
AM or PM |
%p |
| Minute | 05 | mm |
mm |
%M |
MI |
%i |
| Second | 09 | ss |
ss |
%S |
SS |
%s or %S |
| Milliseconds | 123 | SSS |
SSS |
none (%f is microseconds) |
MS |
none (%f is microseconds) |
| Offset | +05:30 | xxx or XXX |
Z |
%:z (Python 3.12+) |
TZH:TZM (OF omits :00 minutes) |
none |
| Offset, compact | +0530 | xx or XX |
ZZ |
%z |
TZHTZM |
none |
Read down any column and the conventions are internally consistent. Read across a row and the collisions become obvious.
The letters that collide
| You write | You probably meant | What you get | Where |
|---|---|---|---|
YYYY |
Calendar year | Week-numbering year: wrong in late December and early January | LDML (Java, date-fns) |
DD |
Day of month | Day of year: 2026-02-45 on 14 February |
LDML |
mm |
Month | Minutes: 26/05/2026 at 14:05 |
LDML, Moment |
hh |
24-hour clock | 12-hour clock with no AM/PM marker: 02:05 at 14:05 |
LDML, Moment |
%M |
Minutes | Month name | MySQL (in Python and C, %M is minutes) |
HH |
24-hour clock | 12-hour clock | PostgreSQL and Oracle (HH is HH12) |
yyyy in a strict parser |
Year | "Unable to obtain LocalDate": year-of-era needs an era | Java with ResolverStyle.STRICT |
YYYY |
Week-numbering year | Calendar year | Moment.js and Day.js (the reverse surprise) |
Why YYYY exists at all
Week-numbering years support week-based reporting: "2026-W53" needs a year that the week belongs to, and the last days of December can belong to week 1 of next year. ISO 8601 defines one such scheme (weeks start Monday, week 1 contains the first Thursday). Locales define others: in the United States, weeks start on Sunday and week 1 is the one containing 1 January. Java's YYYY and date-fns's YYYY follow the locale's week rules, so the same code can print different years in different locales. Enter 31 December 2026 into the Date Formatter:
2026-12-31With the pattern YYYY-MM-dd it prints 2027-12-31 and warns: `YYYY` is week-numbering year; you probably want `yyyy`. With yyyy-MM-dd it prints 2026-12-31. And with the ISO week pattern RRRR-'W'II it prints 2026-W53, because under ISO rules 2026 has 53 weeks. So on the same day, the locale week-year says 2027 and the ISO week-year says 2026. Java behaves identically: YYYY with Locale.US gives 2027 for 27 December 2026, and with Locale.UK gives 2026, and even formats 1 January 2027 as 2026-01-01.
date-fns refuses YYYY and DD by default and throws a RangeError telling you to use yyyy and dd, unless you pass useAdditionalWeekYearTokens or useAdditionalDayOfYearTokens. Java has no such guard, which is why this bug has shipped in production systems many times; the classic outcome is a service that rejects or misfiles every request for a few days each year-end.
hh without a
hh is the 12-hour clock, so yyyy-MM-dd hh:mm prints 2026-09-26 02:05 for 14:05, with nothing to show that it is afternoon. If you want a 24-hour clock, use HH. If you want 12-hour, always pair hh with a. The Date Formatter flags the pattern with "hh is the 12-hour clock; add a for AM/PM or use HH for 24-hour time." For log files and anything machine-read, 24-hour is the only sensible choice.
Platform notes
- Python
strftimedelegates to the C library, so non-standard codes vary:%-d(no padding) works on Linux and macOS, while Windows uses%#d.%fis microseconds (six digits); to print milliseconds, slice or useisoformat(timespec="milliseconds").%:zarrived in Python 3.12; on older versions,isoformat()is the easiest way to get+05:30. - PostgreSQL
to_charpads names to nine characters (MonthgivesSeptemberbutMay). PrefixFMto suppress padding. Text that is not a pattern must be double-quoted, as in'YYYY"-W"IW', or letters in it may be interpreted. - MySQL
DATE_FORMAThas no offset token, and uses%ifor minutes because%Mis taken by the month name. Convert zones withCONVERT_TZbefore formatting. - Java distinguishes
yyyy(year of era, always positive) fromuuuu(proleptic year, can be zero or negative). With the defaultSMARTresolver both work for modern dates; withSTRICT, useuuuu. Quote literal text with single quotes:yyyy-MM-dd'T'HH:mm. - date-fns follows LDML closely and also quotes literals with single quotes.
doprints an ordinal (26th), which Java cannot do with a pattern. - Moment and Day.js use square brackets for literals (
[W]WW) and useDofor ordinals. Day.js follows Moment's letters, but several of them (ISO week, week-year, ordinals) only work after loading the corresponding plugin.
How to avoid the whole category
- For machine-readable output, do not write a pattern. Use the built-in ISO 8601 serialiser:
toISOString(),isoformat(),DateTimeFormatter.ISO_OFFSET_DATE_TIME, or, in PostgreSQL,to_char(ts AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'). (PostgreSQL'sOFprints+00for UTC, without minutes, which strict RFC 3339 parsers reject.) The ISO 8601 guide covers the variants. - For human-readable output, prefer locale-aware styles (
Intl.DateTimeFormatwithdateStyle,DateTimeFormatter.ofLocalizedDate) over hand-written patterns. - When you must write a pattern, test it with a date in the last week of December and a time after noon. Those two inputs catch
YYYY,hhand mostmmmistakes. - When porting a pattern between languages, translate it token by token from a table like the one above rather than by eye. The Date Formatter generates the equivalent JavaScript, Python, Java, MySQL and PostgreSQL snippets from one LDML pattern, which you can then check against these rules. Each snippet carries only the notes that apply to its language — the padding and
%:zcaveats on Python, the missing offset token on MySQL — and a token with no equivalent, such as the locale weekww, is flagged rather than translated; use the ISO weekIIinstead.