Why does YYYY give the wrong year?

Date Format Patterns Across Languages

· Open the Date Formatter

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:

  1. 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.
  2. Moment-style letters: Moment.js and Day.js. Similar-looking, but YYYY and DD mean what most people expect, which makes code moving between the two families dangerous.
  3. strftime percent codes: C, Python, Ruby, PHP's date alternatives, shell date, Go's strftime ports.
  4. Database-specific: PostgreSQL and Oracle's to_char picture strings, MySQL's DATE_FORMAT percent codes, which are close to strftime but 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:

A date where YYYY and yyyy disagreeOpen in Date Formatter
2026-12-31

With 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 strftime delegates to the C library, so non-standard codes vary: %-d (no padding) works on Linux and macOS, while Windows uses %#d. %f is microseconds (six digits); to print milliseconds, slice or use isoformat(timespec="milliseconds"). %:z arrived in Python 3.12; on older versions, isoformat() is the easiest way to get +05:30.
  • PostgreSQL to_char pads names to nine characters (Month gives September but May ). Prefix FM to 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_FORMAT has no offset token, and uses %i for minutes because %M is taken by the month name. Convert zones with CONVERT_TZ before formatting.
  • Java distinguishes yyyy (year of era, always positive) from uuuu (proleptic year, can be zero or negative). With the default SMART resolver both work for modern dates; with STRICT, use uuuu. Quote literal text with single quotes: yyyy-MM-dd'T'HH:mm.
  • date-fns follows LDML closely and also quotes literals with single quotes. do prints an ordinal (26th), which Java cannot do with a pattern.
  • Moment and Day.js use square brackets for literals ([W]WW) and use Do for 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

  1. 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's OF prints +00 for UTC, without minutes, which strict RFC 3339 parsers reject.) The ISO 8601 guide covers the variants.
  2. For human-readable output, prefer locale-aware styles (Intl.DateTimeFormat with dateStyle, DateTimeFormatter.ofLocalizedDate) over hand-written patterns.
  3. 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, hh and most mm mistakes.
  4. 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 %:z caveats on Python, the missing offset token on MySQL — and a token with no equivalent, such as the locale week ww, is flagged rather than translated; use the ISO week II instead.

Tools used in this guide