How to Format Date and Time in Java
Java 13 min read
DateTimeFormatter patterns that mean something other than what they look like, the locale that changes the output of code you did not touch, and why formatting an Instant throws.
Formatting a date in Java is one method call and three decisions: which pattern letters, which locale, and which zone. Two of those default to something inherited from the environment, and the pattern letters include several pairs that look interchangeable and are not.
Written against Java 17.
The basic call
LocalDate date = LocalDate.of(2026, 8, 25);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
String text = date.format(formatter); // 25/08/2026
LocalDate parsed = LocalDate.parse(text, formatter);
format on the temporal object and parse on the type are the two directions. DateTimeFormatter
is immutable and thread-safe, which is the headline difference from
SimpleDateFormat, a formatter can be a static final constant, and should be, because building one is not free.
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
The pattern letters that are not what they look like
Four pairs cause almost all pattern bugs.
yyyy and YYYY. y is the calendar year. Y is the week-based year, which belongs to the
ISO week numbering and differs from the calendar year for a few days around New Year. Formatting
2026-12-28 with YYYY yields 2027. Use yyyy unless you are also using ww for a week number.
MM and mm. M is the month, m is the minute. Lower case in a date pattern gives you the
minute of the hour where you wanted August.
DD and dd. d is the day of the month, D is the day of the year. DD on 25 August is
237. Worse, DD on a date in the first nine days of the year throws, because the field needs three
digits and two were requested.
hh and HH. h is the 12-hour clock and needs a for AM/PM to be unambiguous. H is the
24-hour clock. hh:mm alone renders 14:32 as 02:32.
The rest of the common set:
| Letter | Meaning | 2026-08-25T14:32:07 |
|---|---|---|
yyyy | year | 2026 |
MMM / MMMM | month name | Aug / August |
dd | day of month | 25 |
EEE / EEEE | day of week | Tue / Tuesday |
HH | hour, 24h | 14 |
mm | minute | 32 |
ss | second | 07 |
SSS | fraction | 481 |
a | AM/PM | PM |
z / zzzz | zone name | CEST / Central European Summer Time |
XXX | ISO offset | +02:00 |
VV | zone id | Europe/Berlin |
Repeating a letter widens the field: M is 8, MM is 08, MMM is Aug, MMMM is August,
MMMMM is A.
To put literal text in a pattern, quote it with single quotes — "yyyy-MM-dd'T'HH:mm:ss". An
unquoted T is not a pattern letter and throws.
Locale changes the output without changing the code
DateTimeFormatter f = DateTimeFormatter.ofPattern("EEEE, d MMMM yyyy");
f.format(date); // depends on the JVM default locale
f.withLocale(Locale.GERMAN).format(date); // Dienstag, 25 August 2026
f.withLocale(Locale.ENGLISH).format(date); // Tuesday, 25 August 2026
Any pattern containing MMM, EEE, a or a zone name reads the locale, and the default comes from
the operating system. A report generated on a developer’s machine and on a server can differ in
language with no code change between them.
Set it explicitly whenever the output is consumed by a machine or has a specified language, and use
Locale.ROOT for a locale-neutral rendering.
The reverse case matters more than it seems: parsing is locale-sensitive too, so a formatter that
parses "25 August 2026" on one machine fails on another.
Localised formats instead of patterns
When the output is for a person and the exact shape does not matter, let the locale decide:
LocalDate date = LocalDate.of(2026, 8, 25);
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.UK).format(date); // 25 Aug 2026
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.US).format(date); // Aug 25, 2026
FormatStyle runs SHORT, MEDIUM, LONG, FULL. This is the right choice for a user interface,
because a hard-coded dd/MM/yyyy is wrong for a US reader and MM/dd/yyyy is wrong for everyone
else — and 01/02/2026 is unreadable either way.
Formatting an Instant throws
DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
f.format(Instant.now()); // UnsupportedTemporalTypeException: Unsupported field: YearOfEra
An Instant is a count from the epoch. It has no year, month or hour until a zone says which ones,
so the formatter asks for a field the object does not have. Two fixes:
f.withZone(ZoneId.of("Europe/Berlin")).format(Instant.now());
// or
f.format(Instant.now().atZone(ZoneId.of("Europe/Berlin")));
withZone is the neater form when the formatter is a constant used for instants throughout.
The built-in ISO formatters
DateTimeFormatter.ISO_LOCAL_DATE.format(date); // 2026-08-25
DateTimeFormatter.ISO_INSTANT.format(Instant.now()); // 2026-08-25T12:32:07.481Z
DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(offsetTime); // 2026-08-25T14:32:07.481+02:00
DateTimeFormatter.RFC_1123_DATE_TIME.format(zoned); // Tue, 25 Aug 2026 14:32:07 +0200
For any machine-readable output, use these rather than a hand-written pattern. They are correct,
locale-independent, and toString() on the java.time types already produces the same text — so
serialising an Instant needs no formatter at all.
RFC_1123_DATE_TIME is the HTTP header format, which is the one case where a non-ISO shape is
mandatory.
Parsing leniently
Strict parsing rejects anything the pattern does not describe exactly. When input is untidy, a builder gives you optional sections and case-insensitivity:
DateTimeFormatter flexible = new DateTimeFormatterBuilder()
.parseCaseInsensitive()
.appendPattern("yyyy-MM-dd")
.optionalStart()
.appendPattern(" HH:mm")
.optionalEnd()
.parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
.toFormatter();
LocalDateTime a = LocalDateTime.parse("2026-08-25", flexible); // 00:00
LocalDateTime b = LocalDateTime.parse("2026-08-25 14:32", flexible);
parseDefaulting is what makes the first line work: without it the optional section is absent, the
hour field is missing, and LocalDateTime.parse throws even though the text matched.
Catch DateTimeParseException specifically — it carries the index at which parsing failed, which is
the useful part of the message.
Where formatting belongs
Most formatting bugs are placement bugs: a date turned into a string too early, carried through three
layers as text, and parsed back somewhere else. The rule that avoids nearly all of them is to keep
temporal values as java.time types everywhere inside the application and format only at the edge
that displays them.
There are two edges, and they want different things. A user interface wants a localised format in the reader’s locale and zone, which the caller supplies rather than the server assuming. An API wants ISO-8601 in UTC, always, with no locale involved at all.
In a Spring application both are configuration rather than code. Jackson serialises java.time types
as ISO-8601 once jackson-datatype-jsr310 is on the classpath, which the web starter brings in:
spring.jackson.serialization.write-dates-as-timestamps=false
Without that property Jackson writes epoch numbers instead, which is valid and unreadable. For a single field that genuinely needs a different shape:
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate invoiceDate;
Request parameters go the other way and need @DateTimeFormat:
public List<Invoice> find(@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from) { }
Without the annotation Spring cannot convert the string and returns a 400 that blames the parameter type rather than the missing format.
Formatting a duration
Duration has no format method, and its toString() produces ISO-8601 (PT2H30M), which is
correct and unreadable. For a human-facing value, do the arithmetic:
Duration d = Duration.ofSeconds(9045);
String text = String.format("%d:%02d:%02d", d.toHours(), d.toMinutesPart(), d.toSecondsPart());
// 2:30:45
toMinutesPart and toSecondsPart were added in Java 9 and are the reason this no longer needs
modulo arithmetic. Note that toHours() is the total, not a part — there is no toHoursPart on a
Duration because there is no larger unit to be a part of.
Related: getting the current date and time, comparing dates, and parsing a string to a date.
Frequently asked questions
Is DateTimeFormatter thread-safe?
Yes — it is immutable, unlike SimpleDateFormat. Declare it
static final and share it.
What is the difference between yyyy and YYYY?
y is the calendar year, Y is the week-based
year used with ISO week numbering. They differ around New Year, which is when the bug ships.
Why is my month showing as minutes?
The pattern used mm instead of MM. Lower case m is the
minute of the hour.
Why does DD give a three-digit number?
D is the day of the year, not the day of the month. Use
dd.
Why does hh show 02 instead of 14?
h is the 12-hour clock. Use HH for 24-hour, or add a for
the AM/PM marker.
How do I put a literal T in a pattern?
Quote it: "yyyy-MM-dd'T'HH:mm:ss". An unquoted letter is
interpreted as a pattern field and throws if it is not one.
Why does the same formatter print a different language on the server?
It reads the JVM default
locale for month and day names. Call withLocale explicitly, or use Locale.ROOT for neutral output.
Why can’t I format an Instant?
It has no year, month or hour without a zone. Use
formatter.withZone(zoneId) or convert with atZone first.
Should I write my own ISO pattern?
No. Use DateTimeFormatter.ISO_LOCAL_DATE and friends, or
just toString() — both are correct and locale-independent.
How do I parse a string where the time part is optional?
Build the formatter with
optionalStart() / optionalEnd() and add parseDefaulting for the fields that may be absent,
otherwise parsing into a LocalDateTime fails on the shorter form.