DateTimeFormatter in Java: Formatting and Parsing Dates with Patterns
The line LocalDate.parse("March 5, 2018", DateTimeFormatter.ofPattern("MMMM d, yyyy")) passes every test on a developer laptop in the US and then fails on a server in Frankfurt with DateTimeParseException: Text 'March 5, 2018' could not be parsed at index 0. The pattern is fine. ofPattern() without a second argument takes the JVM's default locale, and a server with de_DE expects «März», not «March».
DateTimeFormatter in Java (java.time.format.DateTimeFormatter) is an immutable, thread-safe class from Java 8 that turns a date or time into a string (formatting) and a string back into a date or time (parsing). You create one from a pattern with DateTimeFormatter.ofPattern("MM/dd/yyyy"), take a ready-made constant such as ISO_LOCAL_DATE, or use a locale-specific style with ofLocalizedDate(). It works with every java.time class — LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Instant — and replaces the old SimpleDateFormat.
How to create a DateTimeFormatter: ofPattern() and other methods
Most often a DateTimeFormatter is created with the static method ofPattern(String pattern) or ofPattern(String pattern, Locale locale). The pattern string describes the date and time format, and the Locale sets the language of month and day names. Without a locale, the formatter uses Locale.getDefault(Locale.Category.FORMAT) — the JVM's default locale.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class OfPatternExample {
public static void main(String[] args) {
LocalDateTime dateTime = LocalDateTime.of(2024, 3, 15, 14, 30, 45, 123_456_789);
DateTimeFormatter f1 = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss");
DateTimeFormatter f2 = DateTimeFormatter.ofPattern("MMMM, dd, yyyy HH:mm:ss", Locale.US);
DateTimeFormatter f3 = DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy", Locale.GERMANY);
DateTimeFormatter f4 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
System.out.println(dateTime.format(f1));
System.out.println(dateTime.format(f2));
System.out.println(dateTime.format(f3));
System.out.println(f4.format(dateTime)); // same as dateTime.format(f4)
}
} 03/15/2024 14:30:45
March, 15, 2024 14:30:45
Freitag, 15. März 2024
2024-03-15T14:30:45.123 Letters that are not pattern symbols must be wrapped in single quotes, like 'T' in the last pattern. Without quotes, ofPattern("yyyy-MM-ddTHH:mm") throws IllegalArgumentException: Unknown pattern letter: T as soon as the formatter is created. The characters ., :, -, /, , and the space can be written as they are.
The main methods of the class:
| Method | What it does | Edge case / error |
|---|---|---|
ofPattern(pattern), ofPattern(pattern, Locale) | Formatter from a pattern | Unquoted letter — IllegalArgumentException: Unknown pattern letter |
ISO_LOCAL_DATE, ISO_DATE_TIME and others | Ready-made ISO-8601 constants | ISO_INSTANT on a LocalDateTime — UnsupportedTemporalTypeException: Unsupported field: InstantSeconds |
ofLocalizedDate(FormatStyle), ofLocalizedTime(), ofLocalizedDateTime() | The format customary in a locale | LONG/FULL with time on a LocalDateTime — DateTimeException |
withLocale(Locale), withZone(ZoneId), withResolverStyle() | Copy of the formatter with another locale, zone or parsing strictness | The original object does not change — assign the result |
format(TemporalAccessor) | Date/time → string | A required field is missing — UnsupportedTemporalTypeException |
parse(text), parse(text, LocalDate::from) | String → date/time | The text does not match the pattern — DateTimeParseException ... at index N |
toFormat() | java.text.Format adapter for the old API | Needed only if a library accepts a Format |
format() and parse(): date to string and back
To turn a date into a string, call date.format(formatter) or formatter.format(date) — the result is the same. To parse a string, use the static parse(text, formatter) of the target class (LocalDate.parse, LocalDateTime.parse) or formatter.parse(text, LocalDate::from). The whole string must match the pattern, otherwise you get DateTimeParseException.
A LocalDateTime formatter and a LocalDate formatter in action:
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class FormatLocalDateTimeExample1 {
public static void main(String[] args) {
DateTimeFormatter formatter1 = DateTimeFormatter.ofPattern("MMMM d, yyyy HH:mm:ss", Locale.US);
LocalDateTime localDateTime = LocalDateTime.parse("June 5, 2018 12:10:56", formatter1);
System.out.println(localDateTime);
DateTimeFormatter formatter2 = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMANY);
LocalDate localDate = formatter2.parse("5. Juni 2018", LocalDate::from);
System.out.println(localDate);
System.out.println(localDate.format(formatter2));
}
} 2018-06-05T12:10:56
2018-06-05
5. Juni 2018 Without the Locale argument the same code depends on the machine: with a JVM locale of de_DE, the first parse throws DateTimeParseException: Text 'June 5, 2018 12:10:56' could not be parsed at index 0. The one-argument formatter.parse(text) returns not a date but a TemporalAccessor (a set of parsed fields) — you would still have to turn it into a LocalDate, so parse(text, LocalDate::from) is the shorter path.
Parsing ISO strings without a formatter is covered in the class lessons: LocalDate: parsing and formatting a string and Instant: formatting with withZone(). For a LocalDateTime, remember that a date-only pattern cannot produce one: LocalDateTime.parse("03/15/2024", ofPattern("MM/dd/yyyy")) fails with «Unable to obtain LocalDateTime from TemporalAccessor» — parse a LocalDate and call atStartOfDay() instead.
DateTimeFormatter pattern letters
A DateTimeFormatter pattern is built from letters, each standing for a date or time field: yyyy is the year, MM the month, dd the day, HH the hour 0–23, mm minutes, ss seconds, SSS milliseconds. Case matters: MM is the month, mm is minutes. The examples below use the moment 2024-03-15T14:30:45.123 (a Friday) in the America/New_York zone.
| Letter | Meaning | Pattern → result |
|---|---|---|
G | Era | G → AD (Locale.US), n. Chr. (Locale.GERMANY) |
u | Year (proleptic, can be negative) | uuuu → 2024 |
y | Year of era | yyyy → 2024; yy → 24 |
Y | Week-based year — not the same as y | YYYY → 2024 (but 2025 for 12/30/2024) |
Q / q | Quarter | Q → 1; QQQ → Q1 (Locale.US) |
M / L | Month (L is the standalone form of the name) | M → 3; MM → 03; MMM → Mar; MMMM → March / März / mars (US / GERMANY / FRANCE) |
w | Week of the week-based year | w → 11 |
d | Day of month | d → 15; dd → 15 (05 for the 5th) |
D | Day of year | D → 75 |
E | Day of week (text) | E → Fri; EEEE → Friday / Freitag / vendredi |
e / c | Localized day of week (number or text) | e → 6 (Locale.US, week starts on Sunday), 5 (Locale.GERMANY / FRANCE, week starts on Monday) |
a | AM/PM marker | a → PM (Locale.US) |
h | Hour 1–12 | hh → 02 |
K | Hour 0–11 | K → 2 |
k | Hour 1–24 (midnight is 24) | k → 14 |
H | Hour 0–23 | HH → 14 |
m | Minutes | mm → 30 |
s | Seconds | ss → 45 |
S | Fraction of a second (number of letters = number of digits) | S → 1; SSS → 123 (milliseconds) |
n | Nano-of-second as a number (no leading zeros) | n → 123000000 |
A | Milli-of-day | A → 52245123 |
VV | Time-zone ID (exactly two letters) | VV → America/New_York |
z | Time-zone name | zzz → EDT; zzzz → Eastern Daylight Time (Locale.US) |
O | Localized offset | O → GMT-4; OOOO → GMT-04:00 |
X | Offset, prints Z for zero | XXX → -04:00 (Z in UTC or London in winter) |
x | Offset, prints +00 for zero | xx → -0400; xxx → -04:00 |
Z | Offset | Z → -0400; ZZZZZ → -04:00 |
' | Escape for text; '' is a literal quote | 'T' → T; 'at' h:mm a → at 2:30 PM |
[ ] | Optional section (may be absent when parsing) | yyyy-MM-dd[ HH:mm] |
The number of letters matters too. For text fields, 1–3 letters give the short form (Mar), 4 the full form (March), 5 the narrow form (M). For numbers, the count is the minimum width padded with zeros: d → 5, dd → 05. Extra letters are rejected: ofPattern("ddd") throws IllegalArgumentException: Too many pattern letters: d. yy is a special case: it does not mean «at least two digits», it means the last two digits of the year. The complete list is in the official DateTimeFormatter Javadoc.
Predefined formatters: ISO_LOCAL_DATE, ISO_DATE_TIME and more
For standard formats DateTimeFormatter offers ready-made constants: ISO_LOCAL_DATE (2024-03-15), ISO_LOCAL_DATE_TIME, ISO_OFFSET_DATE_TIME, ISO_ZONED_DATE_TIME, ISO_INSTANT, BASIC_ISO_DATE, RFC_1123_DATE_TIME and others. You don't create them, they are thread-safe, and they are exactly what parse(text) without a formatter uses. toString() prints a similar ISO format, but not an identical one: LocalDateTime.of(2024, 3, 15, 14, 30).toString() gives 2024-03-15T14:30, while ISO_LOCAL_DATE_TIME gives 2024-03-15T14:30:00.
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
public class PredefinedFormattersExample {
public static void main(String[] args) {
LocalDateTime dt = LocalDateTime.of(2024, 3, 15, 14, 30, 45, 123_000_000);
ZonedDateTime zdt = dt.atZone(ZoneId.of("America/New_York"));
System.out.println(DateTimeFormatter.ISO_LOCAL_DATE.format(dt));
System.out.println(DateTimeFormatter.ISO_LOCAL_TIME.format(dt));
System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(dt));
System.out.println(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zdt));
System.out.println(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(zdt));
System.out.println(DateTimeFormatter.ISO_INSTANT.format(zdt));
System.out.println(DateTimeFormatter.BASIC_ISO_DATE.format(dt));
System.out.println(DateTimeFormatter.ISO_ORDINAL_DATE.format(dt));
System.out.println(DateTimeFormatter.ISO_WEEK_DATE.format(dt));
System.out.println(DateTimeFormatter.RFC_1123_DATE_TIME.format(zdt));
}
} 2024-03-15
14:30:45.123
2024-03-15T14:30:45.123
2024-03-15T14:30:45.123-04:00
2024-03-15T14:30:45.123-04:00[America/New_York]
2024-03-15T18:30:45.123Z
20240315
2024-075
2024-W11-5
Fri, 15 Mar 2024 14:30:45 -0400 ISO formatters print the fraction of a second only when it is present and without trailing zeros: .123 for 123 milliseconds, not .123000000. When parsing, they accept 0 to 9 fraction digits. ISO_INSTANT needs an actual moment in time: it works for ZonedDateTime and Instant, but on a LocalDateTime it throws UnsupportedTemporalTypeException: Unsupported field: InstantSeconds. RFC_1123_DATE_TIME is the format of the HTTP Date and Last-Modified headers, and its names are always in English.
Locale, month names and ofLocalizedDate()
The Locale of a DateTimeFormatter decides the language of month and day names (MMMM, EEEE), the AM/PM marker, the first day of the week for e and Y, and the shape of localized formats. Pass it as the second argument of ofPattern(pattern, locale) or with withLocale(). The same pattern can produce very different text in other languages, including a different grammatical form of the month name.
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
public class LocaleFormatExample {
public static void main(String[] args) {
LocalDate date = LocalDate.of(2024, 3, 15);
System.out.println(date.format(DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.US)));
System.out.println(date.format(DateTimeFormatter.ofPattern("EEEE d MMMM yyyy", Locale.FRANCE)));
for (FormatStyle style : FormatStyle.values()) { // FULL, LONG, MEDIUM, SHORT
DateTimeFormatter f = DateTimeFormatter.ofLocalizedDate(style);
System.out.println(style + ": " + date.format(f.withLocale(Locale.US))
+ " | " + date.format(f.withLocale(Locale.GERMANY)));
}
}
} Output (Java 17, CLDR locale data):
Friday, March 15, 2024
vendredi 15 mars 2024
FULL: Friday, March 15, 2024 | Freitag, 15. März 2024
LONG: March 15, 2024 | 15. März 2024
MEDIUM: Mar 15, 2024 | 15.03.2024
SHORT: 3/15/24 | 15.03.24 MMMM and LLLL look identical in English, but in languages with grammatical cases they are not. MMMM is the form used inside a date («15 of March»), LLLL is the standalone form for a calendar header (Java 9+, CLDR data):
YearMonth march = YearMonth.of(2024, 3);
Locale pl = Locale.forLanguageTag("pl");
Locale ru = Locale.forLanguageTag("ru");
System.out.println(march.format(DateTimeFormatter.ofPattern("MMMM yyyy", pl))); // marca 2024
System.out.println(march.format(DateTimeFormatter.ofPattern("LLLL yyyy", pl))); // marzec 2024
System.out.println(march.format(DateTimeFormatter.ofPattern("MMMM yyyy", ru))); // марта 2024
System.out.println(march.format(DateTimeFormatter.ofPattern("LLLL yyyy", ru))); // март 2024 Localized formats come from the JDK locale data and change between versions: since Java 9 the default is CLDR, so some outputs differ between Java 8 and Java 17 (for example, the Russian MEDIUM style). In Java 8 with the JRE locale provider, LLLL for some locales, including English, prints the month number instead of the name. So for data that is parsed by programs (files, APIs, databases), use an explicit pattern or an ISO formatter, and keep localized formats for display to people.
One more detail: ofLocalizedDateTime(FormatStyle.LONG) and FULL include the time-zone name. For a LocalDateTime, LocalDateTime.of(2024, 3, 15, 14, 30, 45).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL)) throws DateTimeException: Unable to extract ZoneId from temporal 2024-03-15T14:30:45. Use MEDIUM/SHORT or format a ZonedDateTime.
Always pass a Locale
If a pattern contains text fields (MMM, MMMM, EEE, a), always pass a Locale: Locale.US or Locale.ENGLISH for logs and data exchange, the user's locale (for example Locale.GERMANY) for the UI. Since Java 19 there is also the short form Locale.of("de", "DE"); the new Locale("de", "DE") constructors are deprecated since Java 19.
Milliseconds and nanoseconds: SSS, n, appendFraction()
Milliseconds in a DateTimeFormatter pattern are written as SSS, microseconds as SSSSSS, nanoseconds as SSSSSSSSS. The letter S means a fraction of a second, not «milliseconds as a number»: extra digits are cut off without rounding. If the number of fraction digits in the input is not known in advance, you need DateTimeFormatterBuilder.appendFraction().
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
public class MillisFormatExample {
public static void main(String[] args) {
LocalTime t = LocalTime.of(14, 30, 45, 123_456_789);
LocalTime t5ms = LocalTime.of(14, 30, 45, 5_000_000); // 5 milliseconds
System.out.println(t.format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")));
System.out.println(t.format(DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSS")));
System.out.println(t.format(DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSSSSS")));
System.out.println(t5ms.format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")));
System.out.println(t5ms.format(DateTimeFormatter.ofPattern("HH:mm:ss.n"))); // wrong: n is not a fraction
// 0 to 9 fraction digits
DateTimeFormatter flexible = new DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd HH:mm:ss")
.appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
.toFormatter();
System.out.println(LocalDateTime.parse("2024-03-15 14:30:45.5", flexible));
System.out.println(LocalDateTime.parse("2024-03-15 14:30:45", flexible));
}
} 14:30:45.123
14:30:45.123456
14:30:45.123456789
14:30:45.005
14:30:45.5000000
2024-03-15T14:30:45.500
2024-03-15T14:30:45 The fifth line shows a classic bug: n prints the nano-of-second as a plain integer (5,000,000), so 5 ms looks like «.5000000» — half a second. For the fraction of a second, use only S. The opposite problem appears when parsing: ss.SSS requires exactly three digits, so "2024-03-15 14:30:45.5" fails with DateTimeParseException: ... could not be parsed at index 20 — hence appendFraction() in the example.
Java 8 vs Java 9+. The pattern without separators ofPattern("yyyyMMddHHmmssSSS") formats fine in Java 8, but cannot parse "20240315143045123": it throws DateTimeParseException because of a bug in adjacent value parsing with a fraction field (JDK-8031085). Since Java 9 the same code returns 2024-03-15T14:30:45.123. If your project is still on Java 8, build the formatter with DateTimeFormatterBuilder: appendPattern("yyyyMMddHHmmss") followed by appendValue(ChronoField.MILLI_OF_SECOND, 3).
Time zone in a pattern: VV, z, XXX and ZonedDateTime
A time zone is printed in a DateTimeFormatter pattern with VV (zone ID such as America/New_York), z (zone name) and XXX, xx, Z, O (offset from UTC). These fields exist only in ZonedDateTime and OffsetDateTime. To show the same moment in another zone, convert it with withZoneSameInstant() first and then format it.
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class ZoneFormatExample {
public static void main(String[] args) {
ZonedDateTime newYork = ZonedDateTime.of(2024, 3, 15, 14, 30, 0, 0, ZoneId.of("America/New_York"));
System.out.println(newYork.format(DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm XXX")));
System.out.println(newYork.format(DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm Z")));
System.out.println(newYork.format(DateTimeFormatter.ofPattern("HH:mm VV")));
System.out.println(newYork.format(DateTimeFormatter.ofPattern("HH:mm O", Locale.ENGLISH)));
System.out.println(newYork.format(DateTimeFormatter.ofPattern("HH:mm zzz", Locale.US)));
ZonedDateTime london = newYork.withZoneSameInstant(ZoneId.of("Europe/London"));
System.out.println(london.format(DateTimeFormatter.ofPattern("HH:mm XXX")));
System.out.println(london.format(DateTimeFormatter.ofPattern("HH:mm xxx")));
DateTimeFormatter withZoneId = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm VV");
ZonedDateTime tokyo = newYork.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
System.out.println(tokyo.format(withZoneId));
ZonedDateTime parsed = ZonedDateTime.parse("03/15/2024 14:30 America/New_York", withZoneId);
System.out.println(parsed);
}
} 03/15/2024 14:30 -04:00
03/15/2024 14:30 -0400
14:30 America/New_York
14:30 GMT-4
14:30 EDT
18:30 Z
18:30 +00:00
03/16/2024 03:30 Asia/Tokyo
2024-03-15T14:30-04:00[America/New_York] On 15 March 2024 New York is already on daylight saving time (EDT, UTC−4; the clocks moved on 10 March), London is still on GMT, so XXX prints Z there while xxx prints +00:00. In Tokyo it is already the next day. For data exchange choose VV or XXX: a zone ID and an offset are parsed unambiguously. Short names like «CST» are ambiguous (Central Standard Time or China Standard Time) and depend on the locale. A pattern with an offset applied to a LocalDateTime fails with UnsupportedTemporalTypeException: Unsupported field: OffsetSeconds — attach a zone with atZone() first. For an Instant, the zone is set on the formatter itself: formatter.withZone(ZoneId.of("America/New_York")) — see Instant: formatting.
Strict parsing: ResolverStyle, uuuu and yyyy
ResolverStyle decides what a DateTimeFormatter does with a non-existent date when parsing: SMART (the default for ofPattern()) moves the day back to the last day of the month, STRICT throws an exception, and LENIENT rolls extra days into the next month. To validate user input you need STRICT — but together with uuuu, not yyyy.
What does this code print? The same string «04/31/2024» in three modes:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
public class ResolverStyleExample {
public static void main(String[] args) {
String text = "04/31/2024"; // April has 30 days
DateTimeFormatter base = DateTimeFormatter.ofPattern("MM/dd/uuuu");
System.out.println(LocalDate.parse(text, base));
System.out.println(LocalDate.parse(text, base.withResolverStyle(ResolverStyle.LENIENT)));
System.out.println(LocalDate.parse(text, base.withResolverStyle(ResolverStyle.STRICT)));
}
} 2024-04-30
2024-05-01
Exception in thread "main" java.time.format.DateTimeParseException: Text '04/31/2024' could not be parsed: Invalid date 'APRIL 31' By default a typo in the input silently becomes 30 April — for a booking form or a payment date that is a hidden bug. Day 32 or month 13 fail even in SMART: such values are rejected by the field range check. The ISO constants such as ISO_LOCAL_DATE are already STRICT, so LocalDate.parse("2024-04-31") throws the same «Invalid date 'APRIL 31'» error.
Why uuuu? The letter y is the year-of-era, and without an era (G) strict mode cannot resolve the year. So DateTimeFormatter.ofPattern("MM/dd/yyyy").withResolverStyle(ResolverStyle.STRICT) fails even on the valid date «03/15/2024»: DateTimeParseException: Text '03/15/2024' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {...YearOfEra=2024...}. The letter u is the plain proleptic year, and STRICT works with it as expected. In SMART mode there is no difference between yyyy and uuuu for AD dates.
DateTimeFormatterBuilder: flexible formats
DateTimeFormatterBuilder is what you use when a single ofPattern() is not enough: case-insensitive parsing («MAR» and «Mar»), optional parts of the string, default values for missing fields, a custom base for two-digit years or a fraction of variable length. The builder assembles the formatter piece by piece and finishes with toFormatter().
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.util.Locale;
public class BuilderExample {
public static void main(String[] args) {
// 1. Month name in any case
DateTimeFormatter caseInsensitive = new DateTimeFormatterBuilder()
.parseCaseInsensitive() // before appendPattern
.appendPattern("dd MMM uuuu")
.toFormatter(Locale.ENGLISH);
System.out.println(LocalDate.parse("15 MAR 2024", caseInsensitive));
// 2. Optional time, 00:00 by default
DateTimeFormatter optionalTime = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd[ HH:mm]")
.parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
.toFormatter();
System.out.println(LocalDateTime.parse("2024-03-15 14:30", optionalTime));
System.out.println(LocalDateTime.parse("2024-03-15", optionalTime));
// 3. Two-digit year: 1950-2049 instead of 2000-2099
DateTimeFormatter twoDigitYear = new DateTimeFormatterBuilder()
.appendPattern("MM/dd/")
.appendValueReduced(ChronoField.YEAR, 2, 2, 1950)
.toFormatter();
System.out.println(LocalDate.parse("03/15/99", twoDigitYear));
System.out.println(LocalDate.parse("03/15/99", DateTimeFormatter.ofPattern("MM/dd/yy")));
}
} 2024-03-15
2024-03-15T14:30
2024-03-15T00:00
1999-03-15
2099-03-15 Without parseCaseInsensitive() the string «15 MAR 2024» would fail with DateTimeParseException: ... could not be parsed at index 3: the English locale spells the month «Mar». The last line of the output surprises many people: the yy pattern always maps the year to 2000–2099, so «99» becomes 2099, not 1999. For birth dates from legacy systems, use appendValueReduced() with your own base year.
Thread safety: DateTimeFormatter vs SimpleDateFormat
DateTimeFormatter is immutable and thread-safe: one instance can live in a static final field and be used from any number of threads at once without synchronization. The old java.text.SimpleDateFormat keeps intermediate state in an internal Calendar field, so a shared instance produces wrong dates or fails with NumberFormatException under concurrent use.
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class DateFormats {
// Created once, safe for all threads
public static final DateTimeFormatter US_DATE = DateTimeFormatter.ofPattern("MM/dd/uuuu");
public static String format(LocalDate date) {
return date.format(US_DATE);
}
public static void main(String[] args) {
System.out.println(format(LocalDate.of(2024, 3, 15))); // Output: 03/15/2024
}
} The withLocale(), withZone() and withResolverStyle() methods do not modify the formatter, they return a new object — so a shared constant cannot be «broken» by another thread. With SimpleDateFormat you had to create a new object per call or wrap it in a ThreadLocal; with DateTimeFormatter none of that is needed. If you searched for «DateFormatter» in Java: there is no such class in the JDK — the legacy classes are java.text.DateFormat and its subclass SimpleDateFormat, and the modern one is DateTimeFormatter.
Where developers get patterns wrong
Most DateTimeFormatter bugs come from the wrong letter case in a pattern (YYYY vs yyyy, mm vs MM, DD vs dd) and from a forgotten Locale. The code compiles and often works for most of the year; the bug shows up at the end of December or on a server with a different locale.
What does this code print?
LocalDate date = LocalDate.of(2024, 12, 30);
System.out.println(date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
System.out.println(date.format(DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.US))); 2024-12-30
2025-12-30 YYYY is the week-based year: the week from 29 December 2024 to 4 January 2025 counts as week 1 of 2025, so the date jumps a year ahead. For the rest of the year both patterns print the same value, which is why this bug is usually found only in the last days of December.
| Mistake | What happens | Correct approach |
|---|---|---|
YYYY-MM-dd | 12/30/2024 → «2025-12-30» | yyyy or uuuu |
mm/dd/yyyy | For a LocalDateTime at 14:30 → «30/15/2024»; for a LocalDate — UnsupportedTemporalTypeException: Unsupported field: MinuteOfHour | MM/dd/yyyy (MM is the month) |
MM/DD/yyyy | 15 March → «03/75/2024» (day of year) | dd — day of month |
hh:mm without a | 13:45 and 01:45 look the same | HH:mm, or hh:mm a with a Locale |
HH:mm:ss.n for milliseconds | 5 ms → «.5000000» | SSS |
ofPattern("MMMM d, yyyy") without Locale | Parsing «March 5, 2018» fails on a machine with another locale | ofPattern("MMMM d, yyyy", Locale.US) |
MMMM yyyy for a calendar header in Polish or Russian | Gives the «inside a date» form: «marca 2024» | LLLL yyyy → «marzec 2024» |
MM/dd/yy for old dates | «03/15/99» → 2099-03-15 | appendValueReduced(YEAR, 2, 2, 1950) or a four-digit year |
yyyy + ResolverStyle.STRICT | Even a valid date fails to parse | uuuu + STRICT |
yyyy-MM-ddTHH:mm | IllegalArgumentException: Unknown pattern letter: T | yyyy-MM-dd'T'HH:mm or ISO_LOCAL_DATE_TIME |
formatter.withLocale(Locale.GERMANY); without assignment | The formatter does not change | formatter = formatter.withLocale(Locale.GERMANY); |
An invisible space after a JDK upgrade
Since Java 20 (CLDR 42), localized time formats for English put a narrow no-break space U+202F before AM/PM instead of a regular space. The string looks exactly as before, but "2:30 PM" with a regular space no longer parses with ofLocalizedTime(FormatStyle.SHORT), and string comparisons in tests start failing. An explicit pattern such as ofPattern("h:mm a", Locale.US) is not affected.
Frequently asked questions
Can I move a SimpleDateFormat pattern to DateTimeFormatter unchanged?
Usually yes: y, M, d, H, h, m, s, E, a, z, Z and X mean the same in both classes. The differences are in the details: in SimpleDateFormat the letter u is the day-of-week number, in DateTimeFormatter it is the year; S in the old API is a number of milliseconds, in the new one it is a fraction of a second; the pattern ddd is accepted by the old API (day padded to three digits) but rejected by DateTimeFormatter with IllegalArgumentException. After migrating, test the pattern on edge dates such as the last days of December.
How do I parse a date that can arrive in several formats?
Java does not detect the format automatically. If the formats differ only by an optional part, one pattern with square brackets is enough, for example uuuu-MM-dd[ HH:mm] (see DateTimeFormatterBuilder). If the formats are completely different (03/15/2024 and 2024-03-15), combine them for parsing as alternative optional sections [MM/dd/uuuu][uuuu-MM-dd], or try a list of formatters one by one and catch DateTimeParseException.
How do I print a month or day name in upper case or with custom wording?
Month and day names come from the locale data: English gives «March» and «Friday», French gives lower-case «mars» and «vendredi». DateTimeFormatter has no option to change the case, so change it after formatting: date.format(ofPattern("ddMMMyy", Locale.ENGLISH)).toUpperCase(Locale.ENGLISH) turns «15Mar24» into «15MAR24». For your own names, use DateTimeFormatterBuilder.appendText(ChronoField.MONTH_OF_YEAR, map), where the map links each month number to its text. To parse upper-case input, see parseCaseInsensitive() in the builder section.
How do I format a java.util.Date with DateTimeFormatter?
Not directly: Date does not implement TemporalAccessor. Convert it to java.time with an explicit time zone: date.toInstant().atZone(ZoneId.of("America/New_York")).format(formatter), or give the formatter a zone with withZone() and pass date.toInstant(). The full table of conversions between the old and new API is in Date vs LocalDate: converting between the old and new API.
Comments