How to Parse a String to a Date in Java
Java 11 min read
LocalDate, LocalDateTime, ZonedDateTime and Instant from text — plus the two traps that pass every test and fail in production: yyyy under a strict resolver, and month names that depend on the default locale.
Parsing a date is a two-line problem with two well-hidden failure modes. Both pass code review, both pass tests on a developer machine, and both produce wrong dates on a server.
This covers the whole java.time set, then the traps.
Pick the right type first
Half of all date bugs are a type that carries too much information, or too little.
| Type | Represents | Use for |
|---|---|---|
LocalDate | a date, no time, no zone | birthday, invoice date |
LocalTime | a time, no date | opening hours |
LocalDateTime | date and time, no zone | a wall-clock reading, a schedule |
OffsetDateTime | date, time and a fixed UTC offset | an API timestamp |
ZonedDateTime | date, time and a zone with rules | “9am in Berlin, next March” |
Instant | a point on the timeline, UTC | when something happened |
The rule that avoids most trouble: store Instant, display in a zone. A LocalDateTime is not a
moment — 2026-03-29T02:30 did not exist in Berlin, and 2026-10-25T02:30 happened twice.
ISO input needs no formatter
Every type parses its ISO-8601 form directly:
LocalDate d = LocalDate.parse("2026-03-14");
LocalTime t = LocalTime.parse("09:41:30");
LocalDateTime dt = LocalDateTime.parse("2026-03-14T09:41:30");
OffsetDateTime od = OffsetDateTime.parse("2026-03-14T09:41:30+01:00");
ZonedDateTime zd = ZonedDateTime.parse("2026-03-14T09:41:30+01:00[Europe/Berlin]");
Instant i = Instant.parse("2026-03-14T08:41:30Z");
If you control both ends, use these formats and stop here. Almost every “date parsing” problem is really a “someone chose a custom format” problem.
Note the T is required. "2026-03-14 09:41:30" with a space is not ISO and needs a formatter — one
of the most common DateTimeParseException causes there is.
A custom format
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
LocalDateTime dt = LocalDateTime.parse("14/03/2026 09:41", fmt);
Declare formatters static final. Unlike SimpleDateFormat they are immutable and thread-safe,
so one instance serves the whole application; building one per call is pure waste.
The patterns worth memorising:
yyyy / uuuu year MM month number dd day
HH hour 0-23 mm minute ss second
hh hour 1-12 a AM/PM SSS millisecond
MMM Jan MMMM January EEE Mon
XXX +01:00 VV Europe/Berlin zzz CET
MM is month, mm is minute, DD is day-of-year. Swapping MM and mm produces a date that
parses and is wrong, which is the worst category of bug.
Trap one: yyyy under a strict resolver
java.time parses leniently by default and will accept things you would rather it rejected. Turning
on strict resolution is correct — and it breaks yyyy:
// throws: "Unable to obtain LocalDate from TemporalAccessor"
DateTimeFormatter broken = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
LocalDate.parse("2026-03-14", broken);
// correct
DateTimeFormatter strict = DateTimeFormatter.ofPattern("uuuu-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
LocalDate.parse("2026-03-14", strict);
yyyy is year-of-era — it needs an era to be a year, and strict resolution refuses to assume one.
uuuu is the proleptic year, which is unambiguous. Under the default SMART resolver both appear to
work, so this surfaces the moment someone tightens the formatter.
Use uuuu unless you are genuinely formatting with an era. What strict mode buys:
// SMART (default): silently becomes 2026-02-28
LocalDate.parse("2026-02-31", DateTimeFormatter.ofPattern("uuuu-MM-dd"));
// STRICT: throws DateTimeParseException
LocalDate.parse("2026-02-31", strict);
A user typing 31 February should get an error, not the 28th.
Trap two: month names follow the default locale
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd MMM yyyy");
LocalDate.parse("14 Mar 2026", fmt);
This works on your machine and throws on a server whose default locale is German, because there MMM
expects Mär. Nothing in the code mentions a locale, so nothing suggests where to look.
Any pattern containing MMM, MMMM, EEE or a is locale-sensitive. Always be explicit:
private static final DateTimeFormatter EN_MONTH =
DateTimeFormatter.ofPattern("dd MMM uuuu", Locale.ENGLISH);
Locale.ENGLISH for a machine-readable format that happens to use English month names;
Locale.forLanguageTag("de") when you are genuinely parsing German input. Never the platform
default, which is a property of the deployment rather than of the data.
Adding a zone to a local value
A LocalDateTime has no zone, so converting to an Instant requires you to supply one — and the
API makes that explicit rather than guessing:
LocalDateTime local = LocalDateTime.parse("2026-03-14T09:41");
Instant utc = local.toInstant(ZoneOffset.UTC);
Instant berlin = local.atZone(ZoneId.of("Europe/Berlin")).toInstant();
Two answers, an hour or two apart, both correct for their question. This is why LocalDateTime is
the wrong type for “when did this happen”.
The two edge cases:
// spring forward — 02:30 never existed; atZone shifts to 03:30
LocalDateTime.parse("2026-03-29T02:30").atZone(ZoneId.of("Europe/Berlin"));
// autumn back — 02:30 happened twice; atZone picks the earlier (summer) offset
LocalDateTime.parse("2026-10-25T02:30").atZone(ZoneId.of("Europe/Berlin"));
atZone never throws here. It resolves, silently, by documented rules. If a booking system needs to
know that a time was ambiguous, ask before converting:
ZoneId zone = ZoneId.of("Europe/Berlin");
var offsets = zone.getRules().getValidOffsets(local);
if (offsets.size() != 1) {
throw new AmbiguousLocalTimeException(local); // 0 = gap, 2 = overlap
}
Optional parts and several accepted formats
A builder handles input that varies:
private static final DateTimeFormatter FLEXIBLE = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd")
.optionalStart().appendLiteral(' ').appendPattern("HH:mm")
.optionalStart().appendPattern(":ss").optionalEnd()
.optionalEnd()
.parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
.parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
.toFormatter(Locale.ENGLISH)
.withResolverStyle(ResolverStyle.STRICT);
LocalDateTime.parse("2026-03-14", FLEXIBLE); // 00:00:00
LocalDateTime.parse("2026-03-14 09:41", FLEXIBLE); // 09:41:00
LocalDateTime.parse("2026-03-14 09:41:30", FLEXIBLE); // 09:41:30
parseDefaulting is what allows a LocalDateTime to come out of a date-only string; without it the
first call fails because no time field was parsed.
For a handful of unrelated formats, try each in turn and report the input if all fail:
public static LocalDate parseAny(String text) {
for (DateTimeFormatter f : ACCEPTED) {
try {
return LocalDate.parse(text, f);
} catch (DateTimeParseException ignored) {
// try the next
}
}
throw new IllegalArgumentException("unrecognised date: " + text);
}
Failing usefully
DateTimeParseException carries the position of the failure. Pass it on:
try {
return LocalDate.parse(input, EN_MONTH);
} catch (DateTimeParseException e) {
throw new ValidationException(
"Could not read '%s' as a date (expected 14 Mar 2026), problem at position %d"
.formatted(input, e.getErrorIndex()), e);
}
Never swallow it and return null or today’s date. A null propagates to a NullPointerException
somewhere unrelated, and a default of “today” produces plausible wrong data that nobody notices.
SimpleDateFormat, and why not
// legacy, and dangerous
SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
Date d = sdf.parse("14/03/2026");
Three reasons to leave it alone. It is mutable and not thread-safe — a static SimpleDateFormat
shared between threads produces silently wrong dates under load, which is a genuinely nasty
production bug. It is lenient by default, accepting 31 February and month 13. And Date is not a
date: it is a millisecond instant with a deprecated calendar API attached.
When a library hands you a Date, convert at the boundary and keep java.time inside:
Instant instant = legacyDate.toInstant();
LocalDate date = instant.atZone(ZoneId.systemDefault()).toLocalDate();
Date back = Date.from(zonedDateTime.toInstant());
Frequently asked questions
What is the difference between yyyy and uuuu?
yyyy is year-of-era and needs an era to resolve;
uuuu is the proleptic year. Under ResolverStyle.STRICT, yyyy throws — use uuuu unless you are
formatting with an era.
Why does my formatter throw only on the server?
Almost certainly a locale-sensitive pattern —
MMM, MMMM, EEE or a — resolving against a different default locale. Pass an explicit
Locale.
Why is 2026-02-31 accepted?
The default resolver is SMART, which adjusts to the last valid day
of the month. Add .withResolverStyle(ResolverStyle.STRICT) to reject it.
Is DateTimeFormatter thread-safe?
Yes, and immutable. Declare one static final instance and
reuse it. SimpleDateFormat is neither, which is its most dangerous property.
Why can I not parse “2026-03-14 09:41” with LocalDateTime.parse?
ISO requires a T between date
and time. A space needs an explicit formatter.
How do I turn a LocalDateTime into an Instant?
Supply a zone: .atZone(zone).toInstant(), or
.toInstant(ZoneOffset.UTC). There is no default, deliberately — the answer depends on the zone.
What happens parsing a time that does not exist because of daylight saving?
atZone shifts it
forward by the gap and does not throw. Check zone.getRules().getValidOffsets(local) first if you
need to detect gaps and overlaps.
Should I store LocalDateTime or Instant?
Instant for anything that happened. LocalDateTime
only for a wall-clock value whose zone is genuinely irrelevant or stored separately.
How do I parse a date-only string into a LocalDateTime?
Use DateTimeFormatterBuilder with
parseDefaulting for the time fields, or parse a LocalDate and call atStartOfDay().
How do I accept several formats?
Try each formatter in turn and throw with the original input if all fail. Include the expected format in the message — the person reading it is usually the user.
Where should I go next?
The Java guides cover the rest of the standard library,
and Java concurrency basics covers why a shared mutable
SimpleDateFormat is a bug rather than a style preference.