Practical technical guides / Java and JVM

YYYY Is Not the Year: Week-Based-Year Patterns in Java Date Formatting

Uppercase YYYY is the week-based year. Measured dates where it prints the wrong year around New Year 2027 in US and ISO locales, and the test that catches it.

By Sandeep Belgavi · Published 2026-11-12

Uppercase YYYY is the week-based year. Measured dates where it prints the wrong year around New Year 2027 in US and ISO locales, and the test that catches it.

A December bug that ships in March

Every few years someone discovers on the last days of December that their dates say next year. An invoice dated 29 December 2026 is printed as 29 December 2027. A log file rotates into a folder for the wrong year. A nightly export named for the date overwrites last January's file. The code has been in production for months and passed every test, because every test ran on a date in the middle of the year.

The cause is nearly always the same: a date pattern written with uppercase YYYY where lowercase yyyy was meant. In Java's DateTimeFormatter and in the legacy SimpleDateFormat, Y is not the calendar year. It is the week-based year, and for a few days around New Year it differs from the calendar year. With the end of 2026 six weeks away, this guide shows exactly which dates are affected under which locales, why the locale changes the answer, how to find the bug before December does, and which pattern letters to use instead. Every result quoted here was produced by the companion code on JDK 23.0.2 compiled with --release 17.

What a week-based year is

A week-based year groups days into whole weeks. Each week belongs to exactly one week-based year, so a week that straddles New Year has to be assigned to one side. The rule that decides it has two parameters: which day starts the week, and how many days of the new year the first week must contain.

ISO 8601 uses Monday as the first day and requires the first week to contain at least four days of the new year, which is the same as saying it contains the first Thursday. Java exposes this as IsoFields.WEEK_BASED_YEAR and WeekFields.ISO. Other conventions differ. The WeekFields.of(Locale) documentation says the first day of the week and the minimal days in the first week come from the locale's data. For the United States locale that data gives Sunday as the first day and one day as the minimum, so any week that contains 1 January is week 1 of the new year.

The pattern letter Y in DateTimeFormatter.ofPattern formats the week-based year using the formatter's locale. That single fact explains everything below: the same pattern on the same date prints different years in different locales.

The dates, measured

The companion formats each date from 20 December to 10 January with both yyyy and YYYY and lists the days where they disagree. Around New Year 2027:

  • With Locale.US, 27 to 31 December 2026 format as 2027. The US week containing 1 January 2027 starts on Sunday 27 December, so those five days are already in week-based year 2027.
  • With Locale.UK, which follows the ISO rule, 1 to 3 January 2027 format as 2026. 1 January 2027 is a Friday, so the week running from Monday 28 December to Sunday 3 January has only three days in 2027, too few to be its first week; it is week 53 of 2026.

The direction of the error flips with the locale. In US-style locales the end of December jumps forward a year. In ISO-style locales the start of January drops back a year. Either way, YYYY-MM-dd produces a date that looks perfectly valid and is wrong by a year: 2027-12-31 for New Year's Eve 2026 in the US locale, 2026-01-03 for 3 January 2027 in the UK locale.

The number of affected days also varies by year. Across the seven year boundaries from 2024/25 to 2030/31, the companion counts between one and six disagreeing days in the US locale and between zero and three in the UK locale. For the 2028/29 boundary the UK locale has no disagreement at all, which is how a bug like this survives a whole year of production.

DateTimeFormatter wrong = DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.US);
DateTimeFormatter right = DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.US);

LocalDate d = LocalDate.of(2026, 12, 31);
wrong.format(d);   // "2027-12-31"
right.format(d);   // "2026-12-31"

The legacy formatter behaves the same

SimpleDateFormat also defines Y as week year and takes the week rules from the calendar's locale. Formatting the same dates with new SimpleDateFormat("YYYY-MM-dd", Locale.US) in UTC gives the same results as DateTimeFormatter in the companion: 31 December 2026 becomes 2027-12-31. Code that moved from one API to the other kept the bug.

The default locale makes it worse. A formatter created with DateTimeFormatter.ofPattern("YYYY-MM-dd"), without a locale argument, uses the JVM's default locale. The same code then prints different wrong dates on a developer laptop, a CI runner and a production container, depending on the user.language and user.country settings each one inherits.

Which letter to use

For the calendar year, use uuuu or yyyy. In DateTimeFormatter, u is the proleptic year and y is the year of era; they agree for all modern dates and differ only for years before year 1, where year-of-era counts backwards and needs an era field. uuuu is the safer default when you also parse, especially with strict resolving, because yyyy without an era can make strict parsing fail to resolve a date.

When you actually want week-based dates, for example to label a weekly report, say so explicitly and use the ISO formatter rather than a hand-built pattern:

DateTimeFormatter.ISO_WEEK_DATE.format(LocalDate.of(2026, 12, 28));  // "2026-W53-1"
DateTimeFormatter.ISO_WEEK_DATE.format(LocalDate.of(2027, 1, 1));    // "2026-W53-5"

A week-based year printed next to a calendar month and day is almost never meaningful. If a pattern contains Y together with MM or dd, treat it as a bug until someone explains why it is not. Pair Y only with week fields, w for week of week-based year and e or E for the day.

Finding it before December does

Search the codebase for patterns containing an uppercase Y. That covers Java strings passed to ofPattern, SimpleDateFormat, logging configuration (many logging frameworks accept Java-style date patterns in layout strings), file name templates, and SQL or reporting tools that reuse Java patterns.

grep -rnE '"[^"]*Y{2,4}[^"]*"' --include='*.java' --include='*.xml' --include='*.properties' src/

Then add a test that cannot pass for the wrong letter. Tests that use today's date or a mid-year fixture are useless here. Pick dates on both sides of a year boundary and both locales. The companion's disagreements method formats every day in a window with both patterns and returns the days where they differ; the test asserts five such days for the US locale and three for the UK locale around New Year 2027, and none between February and November.

static List<LocalDate> disagreements(LocalDate from, LocalDate to, Locale locale) {
    List<LocalDate> bad = new ArrayList<>();
    for (LocalDate d = from; !d.isAfter(to); d = d.plusDays(1)) {
        if (!fmt(d, "yyyy", locale).equals(fmt(d, "YYYY", locale))) bad.add(d);
    }
    return bad;
}

Static analysis helps as well. Error Prone's MisusedWeekYear check flags a YYYY week year in a date pattern that has no ww week field, and its documented default severity is ERROR. If your build already runs Error Prone, make sure nobody has downgraded or suppressed that check; it only sees patterns that are literals in Java code, so configuration files still need the search above.

Failure modes beyond formatting

  • Parsing: a pattern with YYYY-MM-dd used for parsing can resolve to an unexpected date or fail, depending on the resolver style. Data imported in early January may land a year off.
  • Sorting and partitioning: files and partitions named with the wrong year sort into the wrong place and may overwrite earlier data with the same name.
  • Deduplication keys: keys that include a formatted date can collide across years or split one day into two keys.
  • Cross-language systems: other languages define their own pattern letters. Do not assume a pattern copied from a Java configuration means the same thing in a JavaScript or Python formatter; check each library's table.

What to do this week

Fix the patterns now, not on 27 December. Replace YYYY with uuuu wherever a calendar date is meant, pass an explicit locale to every formatter that produces machine-read output, add the boundary test with both a Sunday-first and an ISO locale, and turn on the compile-time check. Then check the output of anything that already ran across a previous year boundary, such as archived file names or report headers, for the dates listed above.

Run the example yourself

Plain JDK code that prints every date quoted here and a 13-check test runner; compile with --release 17. Companion code on GitHub; its README lists the prerequisites, exact commands, expected output and limits.

Primary references and authorship

Sources read 2026-11-12. Original explanation and synthetic examples by Sandeep Belgavi. Views are my own and do not represent any employer, past or present.