Formatting and Parsing Dates
Turn dates into text and back with DateTimeFormatter, choose the right pattern letters and locale, and reject bad input cleanly.
static final DateTimeFormatter DAY =
DateTimeFormatter.ofPattern("d MMM uuuu", Locale.UK);
String text = DAY.format(date);
LocalDate back = LocalDate.parse(text, DAY);DateTimeFormatter converts between java.time values and text. Unlike the old SimpleDateFormat, it is immutable and thread-safe, so the usual pattern is a static final constant shared by every request.
Built-in formats first
Each type's toString() and parse() already use ISO-8601: LocalDate.parse("2026-10-13") needs no formatter. For machine-to-machine text, stick to those and the constants such as ISO_LOCAL_DATE, ISO_OFFSET_DATE_TIME and ISO_INSTANT. HTTP headers like Date and Last-Modified use RFC_1123_DATE_TIME, which prints Tue, 13 Oct 2026 09:30:00 GMT. It writes days below 10 without a leading zero (Sat, 3 Oct 2026), while HTTP's preferred format wants 03; for response headers, a ofPattern("EEE, dd MMM uuuu HH:mm:ss 'GMT'", Locale.ENGLISH) formatter with withZone(ZoneOffset.UTC) is the safe choice.
Custom patterns
DateTimeFormatter.ofPattern("d MMMM uuuu", Locale.UK) builds a formatter for human-facing text. The letters that cause most bugs:
| Letter | Means | Common mix-up |
|---|---|---|
MM | month | mm is minute |
HH | hour 0-23 | hh is hour 1-12 and needs a |
uuuu | year | YYYY is week-based year |
dd | day of month | DD is day of year |
YYYY is the worst of these. It matches the calendar year for most of the year and then prints the next year for the last few days of December, so 29 December 2025 formats as 2026-12-29. It passes every test written in June. Week rules also come from the locale, so the exact days affected differ between Locale.US and Locale.UK.
Locale
Month and day names come from a Locale. Without one, ofPattern uses the JVM default, which differs between a developer's laptop and a container. Always pass the locale for names, and pass the user's locale when rendering for that user.
Parsing untrusted input
Parsing throws DateTimeParseException, a RuntimeException, so catch it at the API boundary and turn it into a 400 response that names the field and the expected format. getErrorIndex() says where the text went wrong.
Watch the resolver style. Formatters built with ofPattern use SMART resolution, which quietly turns 2026-02-30 into 28 February. Add .withResolverStyle(ResolverStyle.STRICT) to reject it, and use uuuu rather than yyyy with STRICT: yyyy is year-of-era, and strict parsing then also wants an era field, so even valid dates fail. The ISO constants are already strict.
Example
When to use it
- Write the Last-Modified header of a file download with a fixed two-digit-day pattern in GMT, which HTTP clients and caches parse reliably.
- Parse a dd/MM/uuuu date from a legacy CSV import with a strict formatter and report the row and index of bad values.
- Render order dates in confirmation emails in the customer's own locale and zone from one shared formatter constant.
More examples
Turning bad input into a 400
With STRICT resolution 30/02/2026 is rejected instead of silently becoming 28 February. Its error index is 0 because each field parsed and only the combined date was invalid; the other inputs fail where the text stops matching.
static final DateTimeFormatter UK_DATE =
DateTimeFormatter.ofPattern("dd/MM/uuuu").withResolverStyle(ResolverStyle.STRICT);
String parseField(String field, String raw) {
try {
return "200 " + LocalDate.parse(raw, UK_DATE);
} catch (DateTimeParseException e) {
return "400 " + field + " must be dd/MM/yyyy (problem at index " + e.getErrorIndex() + ")";
}
}
void main() {
IO.println(parseField("startDate", "13/10/2026"));
IO.println(parseField("startDate", "2026-10-13"));
IO.println(parseField("startDate", "30/02/2026"));
IO.println(parseField("startDate", "13/10/26"));
}
// Output:
// 200 2026-10-13
// 400 startDate must be dd/MM/yyyy (problem at index 2)
// 400 startDate must be dd/MM/yyyy (problem at index 0)
// 400 startDate must be dd/MM/yyyy (problem at index 6)SMART versus STRICT resolution
SMART clamps an impossible day to the end of the month, which can book a customer on the wrong date. With STRICT, use uuuu: yyyy is year-of-era, which strict mode cannot turn into a date without an era.
void main() {
DateTimeFormatter smart = DateTimeFormatter.ofPattern("uuuu-MM-dd");
DateTimeFormatter strict = smart.withResolverStyle(ResolverStyle.STRICT);
DateTimeFormatter strictYyyy = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
IO.println("smart: " + LocalDate.parse("2026-02-30", smart));
try {
LocalDate.parse("2026-02-30", strict);
} catch (DateTimeParseException e) {
IO.println("strict: " + e.getMessage());
}
try {
LocalDate.parse("2026-02-28", strictYyyy);
} catch (DateTimeParseException e) {
IO.println("yyyy: rejected even a valid date");
}
}
// Output:
// smart: 2026-02-28
// strict: Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30'
// yyyy: rejected even a valid dateRendering for the user's locale and zone
The stored value stays an Instant; only the last step picks a zone and a locale, and the zone can move the date as it does for Tokyo. withLocale returns a new formatter, so one shared constant serves every user.
static final DateTimeFormatter PATTERN = DateTimeFormatter.ofPattern("d MMM uuuu, HH:mm");
String render(Instant at, ZoneId zone, Locale locale) {
return at.atZone(zone).format(PATTERN.withLocale(locale)) + " " + zone.getId();
}
void main() {
Instant shipped = Instant.parse("2026-10-13T21:45:00Z");
IO.println(render(shipped, ZoneId.of("Europe/London"), Locale.UK));
IO.println(render(shipped, ZoneId.of("America/New_York"), Locale.US));
IO.println(render(shipped, ZoneId.of("Asia/Tokyo"), Locale.UK));
}
// Output:
// 13 Oct 2026, 22:45 Europe/London
// 13 Oct 2026, 17:45 America/New_York
// 14 Oct 2026, 06:45 Asia/Tokyo
Discussion