Skip to content
CalliCoder

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:

LetterMeaning2026-08-25T14:32:07
yyyyyear2026
MMM / MMMMmonth nameAug / August
ddday of month25
EEE / EEEEday of weekTue / Tuesday
HHhour, 24h14
mmminute32
sssecond07
SSSfraction481
aAM/PMPM
z / zzzzzone nameCEST / Central European Summer Time
XXXISO offset+02:00
VVzone idEurope/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.