Java Date Formatting
Format Java dates and times into strings with DateTimeFormatter and standard or custom patterns.
Java Date Formatting
Every java.time type has a toString() that produces the ISO-8601 representation: 2025-11-04, 14:30:00, 2025-11-04T14:30:00Z. That's fine for logs and machine-to-machine traffic. For human-facing display ("November 4, 2025" or "4 Nov, 14:30") you need a formatter.
The class is java.time.format.DateTimeFormatter. It is the modern, thread-safe, immutable replacement for java.text.SimpleDateFormat. Cache one as a static final and reuse it across threads forever — no synchronisation, no defensive copying.
Three ways to build a formatter
// 1. Built-in ISO formatters
DateTimeFormatter.ISO_LOCAL_DATE; // 2025-11-04
DateTimeFormatter.ISO_LOCAL_DATE_TIME; // 2025-11-04T14:30:00
DateTimeFormatter.ISO_OFFSET_DATE_TIME; // 2025-11-04T14:30:00-05:00
DateTimeFormatter.ISO_ZONED_DATE_TIME; // 2025-11-04T14:30:00-05:00[America/New_York]
DateTimeFormatter.ISO_INSTANT; // 2025-11-04T19:30:00Z
DateTimeFormatter.BASIC_ISO_DATE; // 20251104
// 2. Localised formatters
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG); // November 4, 2025 (en-US)
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM); // Nov 4, 2025, 2:30:00 PM
// 3. Pattern-based formatters
DateTimeFormatter.ofPattern("dd MMM yyyy"); // 04 Nov 2025
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm zzz"); // 2025-11-04 14:30 ESTThe pattern API is the one you'll use most. The localised one is right when you need a culture-appropriate format and want the JDK to choose the layout for you.
Formatting
The call shape is symmetric on both sides:
String s = formatter.format(temporal);
String s2 = temporal.format(formatter); // same thing, fluent styleBoth work. Most code uses the fluent form.
LocalDate today = LocalDate.now();
String us = today.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")); // 11/04/2025
String iso = today.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2025-11-04
String eu = today.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); // 04.11.2025The pattern alphabet
The big table — the one you'll come back to. Letters are case-sensitive and the count matters.
| Letter | Meaning | Example |
|---|---|---|
y | year | y → 2025, yy → 25, yyyy → 2025 |
M | month | M → 11, MM → 11, MMM → Nov, MMMM → November |
d | day of month | d → 4, dd → 04 |
E | day of week | E → Tue, EEEE → Tuesday |
H | hour 0-23 | H → 14, HH → 14 |
h | hour 1-12 | h → 2, hh → 02 (use with a) |
a | AM/PM | a → PM |
m | minute | m → 5, mm → 05 |
s | second | s → 9, ss → 09 |
S | fraction of second | SSS → 123 (millis) |
n | nanosecond | nnnnnnnnn → 123456789 |
z | zone name | z → EST, zzzz → Eastern Standard Time |
Z | zone offset | Z → -0500, ZZ → -0500, ZZZZ → GMT-05:00 |
X | ISO offset | X → -05, XX → -0500, XXX → -05:00 |
V | zone ID | VV → America/New_York |
Literal text wraps in single quotes:
DateTimeFormatter.ofPattern("EEEE, MMMM d 'at' h:mm a"); // Tuesday, November 4 at 2:30 PMFor a literal single quote, use two: ''.
The most-confused pair is m vs M (lowercase = minute, uppercase = month) and H vs h (uppercase = 0-23, lowercase = 1-12). Most "the time is off by something weird" bugs come from one of these typos.
Localisation: Locale and withLocale
A formatter picks up the JVM default locale unless you tell it otherwise. For "always in English" or "always in German" output, pin the locale:
DateTimeFormatter english = DateTimeFormatter.ofPattern("EEEE, MMMM d", Locale.US);
DateTimeFormatter german = DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN);
DateTimeFormatter french = DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.FRENCH);
today.format(english); // Tuesday, November 4
today.format(german); // Dienstag, 4. November
today.format(french); // mardi 4 novembreFor server-rendered content, always pass a locale. The "default locale of the JVM" is unpredictable on production servers and the source of "looks right on my laptop, wrong on the server" bugs.
Time zone display
ZonedDateTime and Instant are the only types that have zone information. Formatting a LocalDateTime with a pattern that includes z or Z throws — there's no zone to print. Convert first:
ZonedDateTime zdt = ldt.atZone(ZoneId.of("America/New_York"));
zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z")); // 2025-11-04 14:30 ESTFor Instant, the formatter needs a zone too — Instant has none, so display formatters that include any zone-dependent field need a withZone:
DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneId.of("America/New_York"));
f.format(Instant.now()); // formatter supplies the zone for displayWithout withZone, formatting an Instant with a calendar-shaped pattern throws.
Stylized formatters with FormatStyle
The localised factories give you four canonical sizes:
DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT); // 11/4/25 (en-US), 04.11.25 (de-DE)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM); // Nov 4, 2025
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG); // November 4, 2025
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL); // Tuesday, November 4, 2025Same four sizes exist for ofLocalizedTime and ofLocalizedDateTime. Use these when you want the layout to follow the user's locale rather than enforcing one shape. Pair them with .withLocale(...) to pin the locale.
A worked example: one date, six display variants
The program below formats one ZonedDateTime in six common ways: ISO for machine logs, US 12-hour for English users, European 24-hour for German users, a long localised form, a custom pattern with embedded literal text, and an Instant-via-withZone formatter for raw timestamps.
What to take from the run:
- The cached
static final DateTimeFormatterfields are the right shape.DateTimeFormatteris immutable and thread-safe; creating one is cheap but not free, and reusing the same instance everywhere is the JDK-recommended pattern. Don't construct a fresh one inside a hot loop. - The same
ZonedDateTimeproduced six different strings depending on the formatter. The value object never changed; the formatter is the only thing that controls layout. That's the separationDateTimeFormatterexists for — keep the value type clean, push presentation to the formatter. - The "common typo" block printed
14:11forHH:MMbecauseMis month, not minute. The two are the most-confused pair in the pattern alphabet. If your displayed time looks suspiciously like a date component, look at the pattern's case. - The
FormatStyleladder produced four progressively-longer strings. UseFormatStyle.MEDIUMas a sensible default for "show a date to a user without thinking too hard";LONGandFULLfor context where the year and the day of week need to be unambiguous;SHORTfor tight UI spaces. LocalDateTimewith a zone-bearing pattern threw — the formatter needs zone data, andLocalDateTimedoesn't have any. The fix is to convert (ldt.atZone(zone)) or to drop the zone-bearing field from the pattern. Either way, the failure mode is clear at runtime.
What's next
Formatting is the value → string direction. The next chapter, Java Date Parsing, is the inverse — string → value — using the same DateTimeFormatter patterns and the same set of caveats. The two together are the I/O boundary for any code that exchanges dates with users, configs, logs, or remote APIs.
Practice
A web server logs timestamps with `ZonedDateTime.now().format(DateTimeFormatter.ofPattern('yyyy-MM-dd HH:mm'))`. On a German-locale JVM the month gets printed as 'Nov' instead of '11'. What's the most likely cause?