Skip to content
CalliCoder

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.

TypeRepresentsUse for
LocalDatea date, no time, no zonebirthday, invoice date
LocalTimea time, no dateopening hours
LocalDateTimedate and time, no zonea wall-clock reading, a schedule
OffsetDateTimedate, time and a fixed UTC offsetan API timestamp
ZonedDateTimedate, time and a zone with rules“9am in Berlin, next March”
Instanta point on the timeline, UTCwhen 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.