🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
// CAPTURED — OpenJDK 25 Temurin: LocalDate.parse("2026-13-01") — impossible month value form
Exception in thread "main" java.time.format.DateTimeParseException: Text '2026-13-01' could not be parsed: Invalid value for MonthOfYear (valid values 1 - 12): 13
at java.base/java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:2079)
at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:2014)
at java.base/java.time.LocalDate.parse(LocalDate.java:449)
// Sibling form — STRUCTURE mismatch reports the character position instead:
// java.time.format.DateTimeParseException: Text '2026/08/22' could not be parsed at index 4⚡ Quick Fix Works 80% of the time
Read the tail of the message: an Invalid value clause means the data is wrong; an at index clause means the pattern or separators are wrong.
try {
return LocalDate.parse(input);
} catch (DateTimeParseException e) {
throw new BadRequestException(
"Date must be ISO yyyy-MM-dd, got: " + input);
}🧠 Why this Happens
Tap to expand the deep technical explanation
Parsing runs a formatter state machine across the text building field-value pairs, then resolves those fields into a concrete date. Two independent layers can reject input: the PARSER aborts when text structure diverges from the pattern and reports the failing character offset (at index 4), while the FIELD VALIDATOR accepts well-formed digits but rejects impossible ones like month 13, quoting the rule (valid values 1 - 12). Resolution style then decides borderline cases — SMART rolls February 30 into March, LENIENT overflows aggressively, STRICT rejects. Knowing which layer complained turns guesswork into a targeted fix.
The HITEC City Parking Spot Analogy:
A form checker either says your answer sits in the wrong box (at index) or that the value itself cannot exist — nobody has a thirteenth month.
🔁 How to Reproduce Confirm this is your error
Call LocalDate.parse("2026-13-01") for the invalid-value form and LocalDate.parse("2026/08/22") against ISO_LOCAL_DATE for the at-index form. (Lab capture: OpenJDK 25.)
🛠️ Solutions (5 Ways to Fix)
Decode the two message shapes and fix the right layer
👉 Use this when/if the exception arrives from user data or partner feeds and you must decide between fixing data or fixing code.
Invalid value ... : 13 means structure matched but content is impossible — reject or correct the DATA. could not be parsed at index 4 means the text diverged from the PATTERN — fix the formatter (separator, order, locale). One glance at the message tail picks the lane.
if (e.getMessage().contains("at index")) {
// wrong shape -> fix formatter/pattern or trim garbage input
} else {
// invalid value -> fix/reject the data itself
}Defensive boundary parser returning Optional
👉 Use this when/if untrusted strings hit your service and one bad row should not kill the batch.
Wrap parsing in a utility that catches DateTimeParseException and returns Optional.empty() plus a logged warning. Callers decide policy explicitly — no silent defaults, no crashes — and the log keeps the offending text for triage.
static Optional<LocalDate> tryParse(String raw) {
try {
return Optional.of(LocalDate.parse(raw.trim()));
} catch (DateTimeParseException e) {
log.warn("Unparseable date [{}]: {}", raw, e.getMessage());
return Optional.empty();
}
}Choose ResolverStyle deliberately (STRICT needs uuuu)
👉 Use this when/if February 29 on non-leap years must fail instead of rolling.
SMART (default) resolves Feb 30 to Feb 28/29-ish silently; STRICT refuses it. The catch: STRICT only works reliably when the pattern uses uuuu (proleptic year), not yyyy (year-of-era). Mixing yyyy with STRICT quietly re-enables surprises.
DateTimeFormatter strict = DateTimeFormatter
.ofPattern("dd/MM/uuuu", Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT);
LocalDate.parse("30/02/2026", strict); // DateTimeParseException — goodAccept multiple formats with appendOptional chains
👉 Use this when/if legacy feeds send several date conventions and you must parse them all with one entry point.
DateTimeFormatterBuilder.appendOptional stacks alternative sections tried in order; a single formatter then accepts ISO, dd/MM/yyyy, and US styles deterministically without nested try/catch ladders.
DateTimeFormatter multi = new DateTimeFormatterBuilder()
.appendOptional(DateTimeFormatter.ISO_LOCAL_DATE)
.appendOptional(DateTimeFormatter.ofPattern("dd/MM/uuuu"))
.appendOptional(DateTimeFormatter.ofPattern("MM-dd-yyyy"))
.toFormatter(Locale.ROOT);Validate at the edge: converters and Jackson bindings
👉 Use this when/if REST clients keep sending malformed dates and you want clean 400s instead of 500s.
Register a Spring Converter or custom Jackson deserializer that parses once at the boundary and translates DateTimeParseException into a validation error carrying the expected format. Interior code then trusts its LocalDate types completely.
@Component
class DateConverter implements Converter<String, LocalDate> {
public LocalDate convert(String source) {
try { return LocalDate.parse(source); }
catch (DateTimeParseException e) {
throw new IllegalArgumentException(
"Expected yyyy-MM-dd", e);
}
}
}📋 Version Notes
JSR-310 arrives: immutable formatters, three resolver styles, and both message forms born here.
Wording stable; CLDR locale-data updates shift some locale-dependent parses after upgrades.
Identical behavior; regressions usually come from migrated SimpleDateFormat code that silently rolled bad dates.
Unchanged — java.time remains the recommended path over legacy Date/Calendar.
🛡️ How to Prevent This Next Time
Standardize on ISO-8601 for machine interfaces, put ONE parsing utility behind your API boundary, always specify Locale and ResolverStyle explicitly, and unit-test calendar edges (leap years, month boundaries) your users actually hit.