DateTimeFormatter Cheat Sheet: Patterns, the yyyy vs YYYY Bug, Locales and ISO Formats
A Java DateTimeFormatter cheat sheet where every output was run on JDK 25: pattern letters, why YYYY prints next year in late December (36 wrong days in 11 years for en_US and en_IN), the U+202F space that breaks AM/PM parsing since JDK 20, SMART vs STRICT parsing of 31 February, ISO constants, and a shared SimpleDateFormat that returns wrong dates.
You write dd-MM-YYYY, test it with a date in November, and it prints exactly what you expect. Then the last week of December arrives and the same code prints next year. That one capital letter is one of the best-known bugs in Java date formatting, and it is only one of four things about DateTimeFormatter that behave differently from what the pattern looks like it says: the letters you pick, the locale you did not pick, the strictness of parsing, and what you share between threads.
This post is a cheat sheet you can read in order. If you have never used DateTimeFormatter, the first section is for you; by the end you will know what a pattern letter does, why YYYY is almost never what you want, why a time that prints fine will not parse back on a modern JDK, and why 31-02-2026 is a valid date as far as Java is concerned.
Versions tested. JDK Temurin 25.0.5+8 (LTS), Maven 3.9.11, JUnit Jupiter 5.11.0. java.time has been in the JDK since Java 8; the locale data comes from Unicode CLDR, which changed in a way that matters in JDK 20 (covered below, with the OpenJDK issue). Every console block in this post is copied from a file in the companion module’s output/ folder, regenerated by scripts/run-all.sh, and 18 JUnit tests pin the claims so a future JDK update fails the build instead of making the post wrong. The thread-safety counts in the last section change on every run; everything else is deterministic.
A pattern is a template of letters, and how many times you repeat a letter matters
A DateTimeFormatter does two jobs. Formatting turns a date or time value into text; parsing turns text back into a value. In both directions it follows a pattern: a string where each letter stands for one piece of a date (d is the day, M the month, u the year), and anything that is not a letter, like - or /, is copied through unchanged. The second input is a locale, which supplies the words (“Sep”, “Tuesday”) and some of the rules. The formatter holds both, and it never changes once built.
The diagram is the whole model: pattern, locale and value in, text out. The smallest program that shows it applies about fifty different patterns to one fixed moment, so you can see exactly what each letter does. The loop is the heart of PatternLettersDemo.java:
for (String p : patterns) {
System.out.printf("%-7s -> %s%n", p, MOMENT.format(DateTimeFormatter.ofPattern(p, Locale.US)));
}
And this is what the month, weekday and AM/PM letters printed on JDK 25, from 01-pattern-letters.txt. Look at the M rows: the same letter gives a number, an abbreviation, a full name or a single initial depending only on how many times you repeat it.
moment = 2026-11-14T01:17:05.123456789+05:30[Asia/Kolkata] (a Saturday, day 318 of the year)
M -> 11
MM -> 11
MMM -> Nov
MMMM -> November
MMMMM -> N
E -> Sat
EEEE -> Saturday
EEEEE -> S
a -> AM
S -> 1
SSS -> 123
n -> 123456789
A -> 4625123
The letters you will use daily, with the real result for that moment:
Letter
Meaning
Example pattern
Printed
y
year-of-era
yyyy
2026
u
year (signed)
uuuu
2026
M
month
MMM
Nov
d
day of month
dd
14
D
day of year
D
318
E
day of week (text)
EEEE
Saturday
H
hour 0–23
HH
01
h
clock hour 1–12
hh
01
m
minute
mm
17
s
second
ss
05
S
fraction of second
SSS
123
a
AM/PM
a
AM
z
zone name
z
IST
VV
zone id
VV
Asia/Kolkata
XXX
offset, Z for zero
XXX
+05:30
Going deeper: the four hour letters disagree at midnight and noonH, h, k and K all print 1 at 01:17, which is why mixing them up survives testing. At the two awkward hours they split apart (from 01-pattern-letters.txt):
time H h k K
00:17 0 12 24 0
01:17 1 1 1 1
12:17 12 12 12 0
13:17 13 1 13 1
hh:mm shows 12:17 for 00:17, which is right on a 12-hour clock but ambiguous without a. k is the one that prints 24 for midnight, and you almost never want it.
Wrong letters fail loudly at construction time: b throws Unknown pattern letter (see the error table below).
yyyy and YYYY look alike, but YYYY is the week-based year
Lowercase yyyy is the year you think of. Uppercase YYYY is the week-based year: the year that the week containing your date belongs to. Weeks do not line up with January 1, so for a few days around New Year the two letters disagree. Here is one date, formatted both ways (from 02-week-year-bug.txt):
== one date, two letters ==
date = 2026-12-29 (TUESDAY)
dd-MM-yyyy = 29-12-2026
dd-MM-YYYY = 29-12-2027
The diagram shows why the locale changes the answer: each locale defines its own idea of a week, and YYYY prints the year of the week, not of the day. The class that holds that definition is WeekFields, and the demo prints it for four locales (from 02-week-year-bug.txt):
== why: each locale defines what a week is (WeekFields) ==
en_US WeekFields[SUNDAY,1] week-based-year of 2026-12-29 = 2027, week number = 1
en_GB WeekFields[MONDAY,4] week-based-year of 2026-12-29 = 2026, week number = 53
fr_FR WeekFields[MONDAY,4] week-based-year of 2026-12-29 = 2026, week number = 53
en_IN WeekFields[SUNDAY,1] week-based-year of 2026-12-29 = 2027, week number = 1
Notice that en_IN follows the US rule, not the British one. So an Indian-locale server hits this bug on the same days a US one does. How many days is that? Every day from 2020 to 2030, compared (same transcript, 02-week-year-bug.txt):
== how often is YYYY wrong? every day of 2020-2030, compared with yyyy ==
en_US 36 wrong days out of 4018
en_GB 19 wrong days out of 4018
fr_FR 19 wrong days out of 4018
en_IN 36 wrong days out of 4018
Why it ships. In the US and Indian locales YYYY is wrong on 36 of 4,018 days, which is under one day in a hundred, and all of them fall in the last days of December. Test it with any date from the other eleven months and it passes. The fingerprint of this bug: a date printed in the last week of December whose year is one too high, while 1 January onward looks fine.
Which US days exactly? The run lists them, and you can see the window is not fixed — it moves with the weekday that January 1 falls on (from 02-week-year-bug.txt):
== the days that are wrong in the US locale ==
2024/2025: 2024-12-29->2025, 2024-12-30->2025, 2024-12-31->2025
2025/2026: 2025-12-28->2026, 2025-12-29->2026, 2025-12-30->2026, 2025-12-31->2026
2026/2027: 2026-12-27->2027, 2026-12-28->2027, 2026-12-29->2027, 2026-12-30->2027, 2026-12-31->2027
2027/2028: 2027-12-26->2028, 2027-12-27->2028, 2027-12-28->2028, 2027-12-29->2028, 2027-12-30->2028, 2027-12-31->2028
2028/2029: 2028-12-31->2029
You want
Use
Notes
The ordinary calendar year
uuuu (or yyyy for modern dates)
uuuu is the safer habit; see the accordion below
Week-based year, on purpose
YYYY with ww
Only ever together with the week number, ideally via ISO_WEEK_DATE (section on ISO formats)
Going deeper: yyyy is year-of-era, uuuu is the signed year
For any date after year 1 the two print the same number. They differ for dates before year 1, and that difference is also why strict parsing treats them differently (next sections). The run (from 02-week-year-bug.txt):
== yyyy vs uuuu: same for modern dates, different for the era ==
date = -0043-03-15
yyyy = 0044
uuuu = -0043
yyyy G = 0044 BC
yyyy counts years within an era, so 44 BC prints as 0044 and you need G to know it was BC. uuuu prints the signed proleptic year, -0043. Unless you work with historical dates, you will not notice — until you parse strictly.
Finding it in a codebase: search for the literal YYYY inside any quoted pattern string.
Reference: DateTimeFormatter, the pattern letters table. The old SimpleDateFormat defines Y as the week year too, so the trap predates java.time.
Locale decides the words, the abbreviations, and even the space before PM
If you call ofPattern("EEEE, d MMMM uuuu") without a locale, the formatter uses the JVM’s default locale, so the same code prints different text on your laptop and on a server configured differently. Passing the locale is how you make the output a decision instead of an accident. Here is one pattern in four locales, including two Indian languages (from 03-locales.txt):
DateTimeFormatter names = DateTimeFormatter.ofPattern("EEEE, d MMMM uuuu");
for (Locale l : new Locale[]{Locale.US, EN_IN, HI_IN, MR_IN}) {
System.out.printf("%-6s %s%n", l, names.withLocale(l).format(d));
}
== the same pattern in four locales ==
en_US Tuesday, 1 September 2026
en_IN Tuesday, 1 September 2026
hi_IN मंगलवार, 1 सितंबर 2026
mr_IN मंगळवार, 1 सप्टेंबर 2026
Two quieter differences are worth knowing before they surprise you in a report. September abbreviates to Sept, not Sep, in the British and Indian English locales, and the Indian English locale prints AM/PM in lowercase:
== abbreviated month: not what every Indian or British reader expects ==
en_US 1 Sep 2026
en_GB 1 Sept 2026
en_IN 1 Sept 2026
== AM/PM text per locale, with the hex of every character ==
en_US [03:30 PM] 30 33 3A 33 30 20 50 4D
en_IN [03:30 pm] 30 33 3A 33 30 20 70 6D
That lowercase pm is not a bug: it is what the locale data says, and the pattern letter a simply asks the locale. If your output must be uppercase everywhere, pin Locale.ENGLISH instead of en_IN, as the drop-in class at the end of this post does.
The space that is not a space
Now the trap that has cost many teams an afternoon. Format a time with the localized style (ofLocalizedTime(FormatStyle.SHORT)) and look at the characters, one hex code each, from 03-locales.txt:
== ofLocalizedTime(SHORT): the space before PM is not a space ==
en_US 33 3A 33 30 202F 50 4D
en_IN 33 3A 33 30 202F 70 6D
The diagram shows the only difference between the two strings. Since JDK 20 the locale data uses a narrow no-break space (U+202F) before AM/PM in English locales. It looks like a space and is not one, so a parser that wants a normal space refuses the formatted text, and a pattern that was written with a normal space refuses the other. Both directions fail (from 03-locales.txt):
== parsing it back with a plain space ==
DateTimeParseException: Text '3:30 PM' could not be parsed at index 4
15:30
== the other direction: text that CONTAINS U+202F, read by a pattern with a plain space ==
DateTimeParseException: Text '3:30_PM' could not be parsed at index 4
The fingerprint of this bug.DateTimeParseException: Text ‘3:30 PM’ could not be parsed at index 4 on text that looks perfectly valid, usually after upgrading from JDK 17 to 21 or later, or when text formatted on one JDK is parsed on another. Index 4 is the character before AM/PM. Do not retype the text to check it; print the code points.
There are two fixes, and the run shows both work on JDK 25 (from 03-locales.txt). The first is to say what you mean with parseLenient(), which accepts either kind of space in either direction:
DateTimeFormatter lenientShort = new DateTimeFormatterBuilder()
.parseLenient().appendLocalized(null, FormatStyle.SHORT).toFormatter(Locale.US);
DateTimeFormatter lenientPattern = new DateTimeFormatterBuilder()
.parseLenient().appendPattern("h:mm a").toFormatter(Locale.US);
== parseLenient() accepts either kind of space, in both directions ==
localized SHORT, input with plain space : 15:30
pattern h:mm a, input with U+202F : 15:30
The second fix is to stop relying on locale text for anything a machine reads back. Use an ISO constant, or a fixed 24-hour pattern, whenever the value will be stored, sent or parsed later; use localized styles only for text a person reads.
Going deeper: where this change came from, and what else it touches
The CLDR upgrade that introduced the narrow no-break space arrived in JDK 20 (JDK-8284840, which names the CLDR change responsible). The OpenJDK follow-up that allows loose matching of space separators in lenient parsing is JDK-8324665, and the run above shows it working on JDK 25. The Spring Framework wiki has a practical write-up of the whole episode, including a legacy-locale compatibility mode that it reports was removed in JDK 23: Date and Time Formatting with JDK 20 and higher.
What the localized styles print for en_IN (from 03-locales.txt):
== localized styles for en_IN (the pattern is chosen by the locale, not by you) ==
MEDIUM 1 Sept 2026
SHORT 01/09/26
FULL Tuesday, 1 September 2026
The pattern behind SHORT is chosen by the locale, not by you, which is exactly why it is the wrong tool for a stored value.
Parsing is where Java quietly says yes to 31 February
Parsing has a second setting that formatting does not: the resolver style. After the text is split into fields (day 31, month 2, year 2026), the formatter must decide whether those fields make a real date. STRICT says no, SMART (the default) says “close enough” for days that are too large for the month, and LENIENT rolls over into the next month. The same text under all three, from 04-parsing.txt:
== 31 February under three resolver styles (pattern dd-MM-uuuu) ==
STRICT DateTimeParseException: Text '31-02-2026' could not be parsed: Invalid date 'FEBRUARY 31'
SMART 2026-02-28
LENIENT 2026-03-03
The diagram is the practical advice in one picture: if bad input should be an error (a form field, an import file, an API parameter), the default is not enough, and you have to ask for STRICT yourself. SMART still rejects a day that is out of range for any month, as this run shows:
== 30 February and 32 January ==
SMART 30-02-2026 2026-02-28
SMART 32-01-2026 DateTimeParseException: Text '32-01-2026' could not be parsed: Invalid value for DayOfMonth (valid values 1 - 28/31): 32
STRICT has a catch: use uuuu, not yyyy
If you switch to STRICT and keep yyyy, valid dates stop parsing. A year-of-era needs an era to become a date, and a pattern without G never supplies one. The error message is not obviously about this (from 04-parsing.txt):
== STRICT with yyyy: year-of-era needs an era, so it cannot resolve ==
yyyy DateTimeParseException: Text '14-11-2026' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {MonthOfYear=11, DayOfMonth=14, YearOfEra=2026},ISO of type java.time.format.Parsed
uuuu 2026-11-14
The rule to remember. For strict parsing, write uuuu. For formatting, either letter works for modern dates. The error you will see if you forget is “Unable to obtain LocalDate from TemporalAccessor” with YearOfEra in the field list.
Smaller parsing rules that bite
MM demands two digits and M accepts one or two; two-digit years are read into 2000–2099 here, so a birth year of 99 becomes 2099; and month names are case-sensitive unless you build the formatter with parseCaseInsensitive():
== how many digits? MM wants two, M accepts one or two ==
yyyy-MM-dd 2026-1-5 DateTimeParseException: Text '2026-1-5' could not be parsed at index 5
yyyy-M-d 2026-1-5 2026-01-05
yyyy-M-d 2026-01-05 2026-01-05
== two-digit years are read as 2000-2099 ==
01-01-30 -> 2030-01-01
01-01-99 -> 2099-01-01
== month names are case-sensitive unless you say otherwise ==
14 Nov 2026 2026-11-14
14 NOV 2026 DateTimeParseException: Text '14 NOV 2026' could not be parsed at index 3
14 NOV 2026 (parseCaseInsensitive) 2026-11-14
Going deeper: the old SimpleDateFormat has the same lenient default, and it is worseSimpleDateFormat is lenient by default and rolls over, which is the LENIENT column above. You have to call setLenient(false) to get an error (from 04-parsing.txt):
== the old SimpleDateFormat, same 31-02-2026 ==
lenient (default): Tue Mar 03 00:00:00 IST 2026
setLenient(false): ParseException: Unparseable date: "31-02-2026"
If you are migrating old code, this is the behaviour change that most often goes unnoticed: new SimpleDateFormat("dd-MM-yyyy").parse("31-02-2026") returned 3 March, while DateTimeFormatter with the default style returns 28 February. Both accept the input; they accept it differently. The full migration walk-through is in java.util.Date and Calendar to java.time: the complete migration guide.
For machines, use the ISO constants instead of writing a pattern
When the text is going to a database, an API, a log or another program, do not hand-write a pattern. The JDK ships the standard shapes as constants, and they are correct about the things people forget: the T, the offset, the zone. This is one moment run through every one of them (from 05-iso-formats.txt):
Three things in that block are worth a second look. ISO_INSTANT converts to UTC (the Z), so it prints 19:47 for a 01:17 +05:30 moment on the previous day. ISO_ORDINAL_DATE is year plus day-of-year. And ISO_WEEK_DATE is the safe way to print a week-based year: it prints 2026-W53-2 for 29 December 2026, always with the W and the week number, so the year cannot be mistaken for a calendar year.
Shortcut. For the java.time types, toString() is already ISO. LocalDate.toString() gives 2026-12-29 and OffsetDateTime.toString() gives the offset form, as the last lines of the run show.
Going deeper: the one pattern you still write by hand
A hand-written pattern is for formats someone else dictates: a bank file, a filename, a government form. When you need a literal T in such a pattern, it must be quoted. Unquoted, it is a pattern letter that does not exist (from 06-errors.txt):
the T in an ISO pattern, unquoted IllegalArgumentException: Unknown pattern letter: T
the T in an ISO pattern, quoted OK: 2026-11-14T01:17
Most DateTimeFormatter failures come from a short list. These are the real messages from JDK 25, captured by ErrorsDemo.java into 06-errors.txt:
hour pattern on a LocalDate UnsupportedTemporalTypeException: Unsupported field: HourOfDay
zone pattern on a LocalDateTime DateTimeException: Unable to extract ZoneId from temporal 2026-11-14T01:17
unknown pattern letter 'b' IllegalArgumentException: Unknown pattern letter: b
unterminated quote IllegalArgumentException: Pattern ends with an incomplete string literal: uuuu-MM-dd'T
the T in an ISO pattern, unquoted IllegalArgumentException: Unknown pattern letter: T
the T in an ISO pattern, quoted OK: 2026-11-14T01:17
text too short DateTimeParseException: Text '2026-11' could not be parsed at index 7
text too long DateTimeParseException: Text '2026-11-14 extra' could not be parsed, unparsed text found at index 10
date-only text into a LocalDateTime DateTimeParseException: Text '2026-11-14' could not be parsed: Unable to obtain LocalDateTime from TemporalAccessor: {},ISO resolved to 2026-11-14 of type java.time.format.Parsed
month name in the wrong language DateTimeParseException: Text '14 novembre 2026' could not be parsed at index 3
same text, French locale OK: 2026-11-14
Message starts with
What it means
Fix
Unsupported field: HourOfDay
The pattern asks a LocalDate for a time
Format a LocalDateTime, or drop the time letters
Unable to extract ZoneId
A zone letter on a value with no zone
Use ZonedDateTime, or add atZone(...)
Unknown pattern letter
A letter the formatter does not define, often an unquoted T
Quote literals: 'T'
incomplete string literal
An opening quote with no closing one
Close the quote
could not be parsed at index N
The text and pattern part ways at character N
Count N characters into the text; check digits, names, the space before AM/PM
unparsed text found
The pattern matched, then text was left over
Trim the input, or extend the pattern
Unable to obtain LocalDateTime ... resolved to
Date-only text parsed into a type that needs a time
Parse as LocalDate, then call atStartOfDay()
The last two rows of the run show the locale rule once more: the same text, 14 novembre 2026, fails under Locale.US at index 3 (where the month name starts) and parses under Locale.FRANCE. When a parse fails at the first letter of a name, suspect the locale before the pattern.
Share one DateTimeFormatter; never share a SimpleDateFormat
The class description in the JDK 25 Javadoc says DateTimeFormatter is immutable and thread-safe, so one instance can live in a static final field and be used by every request thread. The older SimpleDateFormat is not thread-safe and keeps its working state inside the object, so two threads using one instance overwrite each other. The demo hammers one shared instance of each from 8 threads, 20,000 parses per thread. The two declarations are the whole difference (ThreadSafetyDemo.java):
SimpleDateFormat shared = new SimpleDateFormat("dd-MM-yyyy");
DateTimeFormatter shared = DateTimeFormatter.ofPattern("dd-MM-uuuu");
Result, from 07-thread-safety.txt. The first line is not deterministic; the exact counts change on every run and depend on your CPU count (this sandbox has 2), but in every run here the shared SimpleDateFormat produced both exceptions and silently wrong dates, and the shared DateTimeFormatter never did:
The dangerous part is the “wrong results” column. The exceptions are loud. The wrong dates are not: a parse that returns a different day than the text said carries no error. That is how this bug reaches production — it needs concurrency to appear, so a single-threaded test never sees it.
Going deeper: what to do with legacy code you cannot migrate yet
If you must keep a SimpleDateFormat, give each thread its own instance (for example through a ThreadLocal) or create one per call. Both cost more than switching, which is why the usual advice is to migrate and move on; the migration guide covers the conversion between Date and the java.time types. The Javadoc for SimpleDateFormat carries the thread-safety note.
The test only asserts the safe side (zero failures for DateTimeFormatter); the unsafe side is shown, not asserted, because a race is allowed to get lucky.
A drop-in class that applies all of this
Here is the practical result of the sections above as one small class you can copy into a project. Every formatter is a constant (safe, since it is thread-safe), every locale is pinned (so the output does not move with the server’s default), parsing is STRICT with uuuu, and the machine format is the ISO constant. Download DateFormats.java — it has no dependencies and runs with java DateFormats.java. The constants:
/** 14-11-2026 : display, and strict input. uuuu (not yyyy) so STRICT parsing can resolve it. */
public static final DateTimeFormatter DMY = strict("dd-MM-uuuu");
/** 14-11-2026 13:05 : 24-hour, no AM/PM, so no locale-dependent marker text to go wrong. */
public static final DateTimeFormatter DMY_HM = strict("dd-MM-uuuu HH:mm");
/** 14 Nov 2026 : month text pinned to English so the abbreviation is the same everywhere. */
public static final DateTimeFormatter DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("d MMM uuuu", Locale.ENGLISH);
/** 01:05 PM : the 12-hour form, with the English marker pinned (en_IN would print lowercase "pm"). */
public static final DateTimeFormatter TIME_12H = DateTimeFormatter.ofPattern("hh:mm a", Locale.ENGLISH);
/** Machine-to-machine: use the built-in ISO constant, never a hand-written pattern. */
public static final DateTimeFormatter WIRE = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static DateTimeFormatter strict(String pattern) {
return DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH).withResolverStyle(ResolverStyle.STRICT);
}
/** Parses a dd-MM-uuuu date, returning empty instead of throwing for bad input such as 31-02-2026. */
public static Optional<LocalDate> parseDmy(String text) {
try {
return Optional.of(LocalDate.parse(text, DMY));
} catch (DateTimeParseException e) {
return Optional.empty();
}
}
Running DateFormats.java prints the following. The last two lines of the file’s own output are omitted here; they show that setting the default locale to en_IN does not change TIME_12H, which is the point of pinning it:
The two Optional.empty results are the strictness doing its job: 31-02-2026 is not a real date, and 14-11-26 does not match dd-MM-uuuu. Unlike the earlier demos, this file is not in the Git repository — it is the one piece of code in the post that lives only here, so it is offered as a download.
Should you hand-write patterns at all? Usually not. Use the ISO constants (or toString()) for anything a program will read, use ofLocalizedDate and ofLocalizedTime for text a person will read in their own locale, and keep ofPattern for formats dictated from outside. Treat the letter Y as a smell: if you did not mean week-based year, it is a bug, and if you did, ISO_WEEK_DATE says so more clearly. Also be honest about scope: everything above was run on JDK 25 with CLDR locale data, and locale text (the Sept, the lowercase pm) can change when a JDK ships newer CLDR, which is the same mechanism that caused the U+202F surprise. The tests in the module are there to tell you when that happens.
No Comments yet!