More on Java’s DateTimeFormatter for “flexible” parsing

This is a continuation of http://www.clevercaboose.com/2026/07/15/java-datatimeformatter-appendoptional-issue/

No one would recommend a serious parser that just iterated through an exhaustive list of possibilities. So I continued this obsessive, possessed, labor-of-love a little further. This is a hard problem.

Two big issues with DateTimeFormatter:

  1. It is greedy.
  2. There is no way to specify “one of many”

You can work around “greedy”. You cannot work around “one of many”. All you can do is provide “zero or more of many” and hope that greedy catches failures.

Also, this is an unsolvable problem in the sense that the solution will often be ambiguous. You’d never use this for “backend work”, obviously. And in the UI, you are relying on the user to check that the interpreted value is correct. That is unacceptable in some circles.

It might be acceptable to provide 3 results:

  1. Correct value parsed with confidence. (Green)
  2. A value was parsed but it might be wrong. (Orange)
  3. A value could not be parsed. (Error/Red)

So I soldiered on. Creating a sequential list of optional patterns comes very close to working, but not quite. I ran into an obscure error where enough “[-]” patterns in sequence ate up the optional spaces and swallowed the “-” of the time zone offset “-05:30”.

Search google and you’ll see a suggestions to “nest the optionals” rather than just apply them sequentially. But that only works when you have a required constraint at the end of the chain. The problem for us is that our chain is all optional, so nested optionals have the same outcome as flattened optionals.

In the end, I solved the special cases by appending specific patterns to handle these errors. Ugly but necessary.

This is the best I have come up with so far. First a Builder to wrap a Builder:

package com.mri.util.time;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalField;
import java.util.Locale;

/**
 * Criticism of DateTimeFormatterBuilder:
 * 1) No way to declare oneOfMany.
 * 2) Everything is greedy
 */
public class DateTimeFormatterBuilderBuilder {

    final DateTimeFormatterBuilder builder;
    final Locale locale;

    public DateTimeFormatterBuilderBuilder() {
        this(Locale.ENGLISH);
    }
    public DateTimeFormatterBuilderBuilder(Locale locale) {
        this.locale = locale;
        builder = new DateTimeFormatterBuilder().parseCaseInsensitive().parseStrict();
    }

    public DateTimeFormatter toFormatter() {
        return builder.toFormatter(locale);
    }

    // #region separator

    // This is still optional. No way to make this a requirement
    // Make the space the last check. Otherwise will swallow " -", which catches the zone offsets
    static final String separatorPattern = "[:][/][-][,][.][ ]";
    static final DateTimeFormatter separatorOptional = DateTimeFormatter.ofPattern(separatorPattern);

    public DateTimeFormatterBuilderBuilder appendOptionalSeparator() {
        builder.appendOptional(separatorOptional); // could have also done builder.append(separatorOptional);
        return this;
    }

    // #endregion

    // #region optional descending from month to nano

    /**
     * year4 is required. everything following is optional
     */
    public DateTimeFormatterBuilderBuilder year4MonthDayHourMinSecNanoTzOptional() {
        year4();
        appendOptionalSeparator();
        monthDayHourMinSecNanoTzOptional();
        defaultingAll();
        return this;
    }

    /**
     * year2 is required. everything following is optional
     */
    public DateTimeFormatterBuilderBuilder year2SpaceMonthDayHourMinSecNanoTzOptional() {
        year2();
        appendOptionalSeparator();
        monthDayHourMinSecNanoTzOptional();
        defaultingAll();
        return this;
    }

    public DateTimeFormatterBuilderBuilder year4() {
        builder.appendValue(ChronoField.YEAR, 4); // this is exact
        // builder.appendPattern("yyyy"); //this consumes 2 to 19 characters and is more flexible but can cause problems with neighbors
        return this;
    }

    public DateTimeFormatterBuilderBuilder year2() {
        builder.appendValueReduced(ChronoField.YEAR, 2, 2, LocalDate.of(1980, 1, 1));  // valid values from {@code 1980} to {@code 2079}
        // builder.appendPattern("yy");
        return this;
    }

    /**
     * Still have an issue in this method. Turns out that the greedy year has fewer false parsing errors
     * than specific widths. I don't know why and haven't investigated.
     */
    public DateTimeFormatterBuilderBuilder yearAll() {
        if (true) {
            builder.appendPattern("[yyyy][yy]");
        } else {
            // This has a higher number of erroneous parsings
            DateTimeFormatter year2 = new DateTimeFormatterBuilder().appendValue(ChronoField.YEAR, 4).toFormatter();
            DateTimeFormatter year1 = new DateTimeFormatterBuilder().appendValue(ChronoField.YEAR, 2).toFormatter();
            appendOneOf(year2, year1);
        }
        return this;
    }

    public DateTimeFormatterBuilderBuilder month2() {
        builder.appendValue(ChronoField.MONTH_OF_YEAR, 2);
        return this;
    }

    public DateTimeFormatterBuilderBuilder monthAll() {
        appendPattern("[MMMM][MMM]"); // ChronoField not used for text inputs
        DateTimeFormatter month2 = new DateTimeFormatterBuilder().appendValue(ChronoField.MONTH_OF_YEAR, 2).toFormatter();
        DateTimeFormatter month1 = new DateTimeFormatterBuilder().appendValue(ChronoField.MONTH_OF_YEAR, 1).toFormatter();
        return appendOneOf(month2, month1);
    }

    public DateTimeFormatterBuilderBuilder dayAll() {
        DateTimeFormatter day2 = new DateTimeFormatterBuilder().appendValue(ChronoField.DAY_OF_MONTH, 2).toFormatter();
        DateTimeFormatter day1 = new DateTimeFormatterBuilder().appendValue(ChronoField.DAY_OF_MONTH, 1).toFormatter();
        return appendOneOf(day2, day1);
    }

    public DateTimeFormatterBuilderBuilder day2() {
        builder.appendValue(ChronoField.DAY_OF_MONTH, 2);
        return this;
    }

    public DateTimeFormatterBuilderBuilder hour2() {
        builder.appendValue(ChronoField.HOUR_OF_DAY, 2);
        return this;
    }

    public DateTimeFormatterBuilderBuilder minute2() {
        builder.appendValue(ChronoField.MINUTE_OF_HOUR, 2);
        return this;
    }

    public DateTimeFormatterBuilderBuilder second2() {
        builder.appendValue(ChronoField.SECOND_OF_MINUTE, 2);
        return this;
    }

    public DateTimeFormatterBuilderBuilder hourAll() {
        DateTimeFormatter hour2 = new DateTimeFormatterBuilder().appendValue(ChronoField.HOUR_OF_DAY, 2).toFormatter();
        DateTimeFormatter hour1 = new DateTimeFormatterBuilder().appendValue(ChronoField.HOUR_OF_DAY, 1).toFormatter();
        return appendOneOf(hour2, hour1);
    }

    public DateTimeFormatterBuilderBuilder minuteAll() {
        DateTimeFormatter minute2 = new DateTimeFormatterBuilder().appendValue(ChronoField.MINUTE_OF_HOUR, 2).toFormatter();
        DateTimeFormatter minute1 = new DateTimeFormatterBuilder().appendValue(ChronoField.MINUTE_OF_HOUR, 1).toFormatter();
        return appendOneOf(minute2, minute1);
    }

    public DateTimeFormatterBuilderBuilder secondAll() {
        // this fails in the "MM dd yyy hh mm n tz" case ("05 12 1963 01 01 -05:30")
        // problem is that greedy parsing in the optional separator swallows the space here, and the subsequent nano parsing
        // swallows the minus sign of the time zone offset. And then the timezone parser fails because 05:30 is illegal (sign is required)
        optionalStart();
        DateTimeFormatter second2 = new DateTimeFormatterBuilderBuilder().appendValue(ChronoField.SECOND_OF_MINUTE, 2).toFormatter();
        DateTimeFormatter second1 = new DateTimeFormatterBuilderBuilder().appendValue(ChronoField.SECOND_OF_MINUTE, 1).toFormatter();
        appendOneOf(second2, second1);
        optionalEnd();
        return this;
    }

    public DateTimeFormatterBuilderBuilder secondAllX() {
        optionalStart();
        // moving the optional separator inside the required element so the position rolls back if not parsed.
        // This eliminates the parsing errors (Exception because failed to parse) but greatly increases the parsed errors
        // (parsed but incorrect value), because now the timezone offsets are parsed as nanoseconds.
        DateTimeFormatter second2 = new DateTimeFormatterBuilderBuilder().appendOptionalSeparator().appendValue(ChronoField.SECOND_OF_MINUTE, 2).toFormatter();
        DateTimeFormatter second1 = new DateTimeFormatterBuilderBuilder().appendOptionalSeparator().appendValue(ChronoField.SECOND_OF_MINUTE, 1).toFormatter();
        appendOneOf(second2, second1);
        optionalEnd();
        return this;
    }

    /**
     * The month-day-year version, with everything else being optional.
     * If we use MM for the month, then there could be ambiguity with year-month-day.
     * Could solve it by forcing a space after month. If so, consistency suggests spaces between month, day, and year, and hour.
     * Or could just require a space after year. That is still a unique quirk, but is less visible/likely-problem
     */
    public DateTimeFormatterBuilderBuilder monthDayYearHourMinSecNanoTzOptional() {
        monthAll();
        appendOptionalSeparator();
        dayAll();
        appendOptionalSeparator();
        yearAll();
        appendOptionalSeparator(); // to differentiate between year-month-day
        hourMinSecNanoTzOptional();
        defaultingAll();
        return this;
    }

    /**
     * The monthDayYearHourMinSecNanoTzOptional has one failure case:
     * If there are no seconds, then it swallows the separator. The subsequent nano
     * parser swallows the next separator, which includes the "-" of a ZoneOffset (e.g., -05:30).
     * So the ZoneOffset parser fails on 05:30 because there is no sign.
     * No known solution. So just add another backup case that skips the seconds and nanos
     *
     * BY THE WAY. THIS SHOWS ANOTHER ISSUE: IF THE USER SKIPS SECONDS BUT INCLUDES NANOSECONDS, WHO KNOWS WHAT WILL HAPPEN?
     */
    public DateTimeFormatterBuilderBuilder monthDayYearHourMinTzOptional() {
        monthAll();
        appendOptionalSeparator();
        dayAll();
        appendOptionalSeparator();
        yearAll();
        appendOptionalSeparator(); // to differentiate between year-month-day
        hourMinTzOptional();
        defaultingAll();
        return this;
    }
    public DateTimeFormatterBuilderBuilder year4MonthDayHourMinTzOptional() {
        year4();
        appendOptionalSeparator();
        monthDayHourMinTzOptional();
        defaultingAll();
        return this;
    }

    private DateTimeFormatterBuilderBuilder monthDayHourMinSecNanoTzOptional() {
        monthAll();
        appendOptionalSeparator();
        dayHourMinSecNanoTzOptional();
        return this;
    }

    private DateTimeFormatterBuilderBuilder monthDayHourMinTzOptional() {
        monthAll();
        appendOptionalSeparator();
        dayHourMinTzOptional();
        return this;
    }

    private DateTimeFormatterBuilderBuilder dayHourMinSecNanoTzOptional() {
        dayAll();
        appendOptionalSeparator();
        hourMinSecNanoTzOptional();
        return this;
    }

    private DateTimeFormatterBuilderBuilder dayHourMinTzOptional() {
        dayAll();
        appendOptionalSeparator();
        hourMinTzOptional();
        return this;
    }

    private DateTimeFormatterBuilderBuilder hourMinSecNanoTzOptional() {
        hourAll();
        appendOptionalSeparator();
        minuteAll();
        appendOptionalSeparator();
        secondAll();
        appendOptionalSeparator();
        appendPattern("[n]");
        appendAllTimeZones();
        return this;
    }

    private DateTimeFormatterBuilderBuilder hourMinTzOptional() {
        hourAll();
        appendOptionalSeparator();
        minuteAll();
        appendAllTimeZones();
        return this;
    }

    public DateTimeFormatterBuilderBuilder yyyymmddOptional() {
        year4();
        month2();
        day2();
        appendOptionalSeparator();
        hourMinSecNanoTzOptional();
        return this;
    }

    public DateTimeFormatterBuilderBuilder mmddyyyyOptional() {
        month2();
        day2();
        year4();
        appendOptionalSeparator();
        hourMinSecNanoTzOptional();
        return this;
    }

    // #endregion

    // #region timezones

    // Need a separator before the timezone. Cannot include '-' because that swallows the "minus". Just use a space
    // Make it optional because everything in this series is optional
    private static final String allTimeZonesWithSpacePrefixStr = "[ ][VV][xxxx][XXX][z][zzzz]";
    static final DateTimeFormatter allTimeZonesWithSpacePrefixEnglish = DateTimeFormatter.ofPattern(allTimeZonesWithSpacePrefixStr, Locale.ENGLISH);

    // Tried to improve the previous line but could not. This produces worse results. Still investigating...
    // private static final DateTimeFormatter allTimeZonesWithSpacePrefix = allTimeZonesInferior(Locale.ENGLISH);
    // static DateTimeFormatter allTimeZonesInferior(Locale locale) {
    // DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
    // builder.appendPattern("[ ]");
    // builder.optionalStart().appendZoneOrOffsetId().optionalEnd();
    // builder.optionalStart().appendLocalizedOffset(TextStyle.FULL).optionalEnd();
    // builder.optionalStart().appendLocalizedOffset(TextStyle.SHORT).optionalEnd();
    // for (TextStyle textStyle : TextStyle.values()) {
    // builder.optionalStart().appendZoneText(textStyle).optionalEnd();
    // }
    // return builder.toFormatter(locale);
    // }

    /**
     * Include one of all four timezone formats
     *
     * Symbol Meaning Example
     * VV Full Time Zone ID America/Chicago, Asia/Tokyo
     * z Short Zone Name CST, PST, GMT
     * Z Offset without colons +0530, -0800
     * XXX Offset with colons +05:30, -08:00
     *
     * VV (Zone ID): Parses regional IDs like Europe/Paris or America/New_York.
     * x (Zone Offset): Parses formats like +05 or +0530 (outputs +00 for zero).
     * XX (Zone Offset): Parses formats like +0530 or Z (outputs Z for zero).
     * XXX (Zone Offset with Colon): Parses standard formats like +05:30 or Z.
     * z (Short Zone Name): Parses abbreviations like EST, PST, or GMT
     * zzzz (Long Zone Name): Parses full names like Pacific Standard Time
     */
    public DateTimeFormatterBuilderBuilder appendAllTimeZones() {
        // requiredSpace(); //Cannot be a separator because it swallows the "minus"
        // used cache value when possible
        if (locale == Locale.ENGLISH) {
            return append(allTimeZonesWithSpacePrefixEnglish);
        } else {
            return append(DateTimeFormatter.ofPattern(allTimeZonesWithSpacePrefixStr, locale));
        }
    }
    // #endregion

    public DateTimeFormatterBuilderBuilder defaultingAll() {
        builder.parseDefaulting(ChronoField.MONTH_OF_YEAR, 1);
        builder.parseDefaulting(ChronoField.DAY_OF_MONTH, 1);
        builder.parseDefaulting(ChronoField.HOUR_OF_DAY, 0);
        builder.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0);
        builder.parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0);
        builder.parseDefaulting(ChronoField.NANO_OF_SECOND, 0);
        return this;
    }

    public DateTimeFormatterBuilderBuilder requiredSpace() {
        builder.appendLiteral(' ');
        return this;
    }

    public DateTimeFormatterBuilderBuilder append(DateTimeFormatter formatter) {
        builder.append(formatter);
        return this;
    }

    private DateTimeFormatterBuilderBuilder optionalStart() {
        builder.optionalStart();
        return this;
    }

    private DateTimeFormatterBuilderBuilder optionalEnd() {
        builder.optionalEnd();
        return this;
    }

    public DateTimeFormatterBuilderBuilder appendOptional(DateTimeFormatter formatter) {
        builder.appendOptional(formatter);
        return this;
    }

    public DateTimeFormatterBuilderBuilder appendPattern(String str) {
        builder.appendPattern(str);
        return this;
    }

    public DateTimeFormatterBuilderBuilder appendOptionalPatterns(String... patterns) {
        optionalStart();
        for (String pattern : patterns) {
            optionalStart();
            appendPattern(pattern);
            optionalEnd();
        }
        optionalEnd();
        return this;
    }

    public DateTimeFormatterBuilderBuilder appendLiteral(String str) {
        builder.appendLiteral(str);
        return this;
    }

    public DateTimeFormatterBuilderBuilder appendValue(TemporalField field, int width) {
        builder.appendValue(field, width);
        return this;
    }

    // warning: there is no way to specify that one of them MUST be required
    // just append them all sequentially as optional
    public DateTimeFormatterBuilderBuilder appendOneOf(DateTimeFormatter... formatters) {
        for (DateTimeFormatter formatter : formatters) {
            appendOptional(formatter);
        }
        return this;
    }
}

And obligatory global utility classes:

public final class MriUtilsLocalDate {
package com.mri.util.time;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

//package visible
final class MriUtilsLocalDate {

    public static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter.ISO_DATE; // ISO-8601
    public static final String ISO_DATE = "yyyy-MM-dd"; // corresponds ISO_DATE_FORMATTER
    static {
        MriUtilsTime.formatterMap.put(ISO_DATE, ISO_DATE_FORMATTER);
    }

    // make sure this comes AFTER the statics
    public static final MriUtilsLocalDate INSTANCE = new MriUtilsLocalDate();

    /**
     * A list of DateTimeFormatters that are used in sequence to attempt to parse a String as a LocalDate
     */
    final List
<DateTimeFormatter> sequencedFormatters;

    private MriUtilsLocalDate() {
        sequencedFormatters = new ArrayList<>();
        sequencedFormatters.add(ISO_DATE_FORMATTER);
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().yyyymmddOptional().toFormatter());
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().mmddyyyyOptional().toFormatter());
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().year4MonthDayHourMinSecNanoTzOptional().toFormatter());
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().year2SpaceMonthDayHourMinSecNanoTzOptional().toFormatter());
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().monthDayYearHourMinSecNanoTzOptional().toFormatter());

        //all these other cases to catch perverse issues with minus signs in ZoneOffset and the greedy "-" separator
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().monthDayYearHourMinTzOptional().toFormatter());
        sequencedFormatters.add(new DateTimeFormatterBuilderBuilder().year4MonthDayHourMinTzOptional().toFormatter());
    }

    /**
     * Expect this to be typed in by a user who will inspect the results.
     * So do our best to find a result, but there is a possibility it will be wrong
     */
    LocalDate parseFlexibleLocalDate(CharSequence text) {
        return MriUtilsTimePriv.parseFormatterSequence(sequencedFormatters, text, LocalDate::from);
    }
}

package com.mri.util.time;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

//package visible
final class MriUtilsLocalDateTime {

    public static final DateTimeFormatter ISO_LOCAL_DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
    public static final String ISO_LOCAL_DATE_TIME = "yyyy-MM-dd'T'HH:mm:ss";
    static {
        MriUtilsTime.formatterMap.put(ISO_LOCAL_DATE_TIME, ISO_LOCAL_DATE_TIME_FORMATTER);
    }

    // this no longer works because the yyyy is greedy. Have to use ChronoField4 instead
    // public static final String YYYYMMDDHH = MriUtilsLocalDate.YYYYMMDD+"HH";

    // make sure this comes AFTER the statics
    public static final MriUtilsLocalDateTime INSTANCE = new MriUtilsLocalDateTime();

    /**
     * A large list of DateTimeFormatters that are used in sequence to attempt to parse a String as a LocalDate
     */
    final List
<DateTimeFormatter> sequencedFormatters;

    private MriUtilsLocalDateTime() {
        sequencedFormatters = new ArrayList<>();
        sequencedFormatters.add(ISO_LOCAL_DATE_TIME_FORMATTER);
        sequencedFormatters.addAll(MriUtilsLocalDate.INSTANCE.sequencedFormatters);
    }

    /**
     * Expect this to be typed in by a user who will inspect the results.
     * So do our best to find a result, but there is a possibility it will be wrong
     */
    LocalDateTime parseFlexibleLocalDateTime(CharSequence text) {
        return MriUtilsTimePriv.parseFormatterSequence(sequencedFormatters, text, LocalDateTime::from);
    }
}

package com.mri.util.time;

import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;
import org.jspecify.annotations.Nullable;

//package visible
final class MriUtilsInstant {

    // ISO-8601 '2011-12-03T10:15:30Z'
    public static final DateTimeFormatter ISO_INSTANT = DateTimeFormatter.ISO_INSTANT;

    // Standard iso format for zoneddatetime/instant parsing. user includes timezone
    public static final String MM_DD_YYYY_HH_MM_SS_NANO_ZONE = "MM/dd/yyyy HH:mm:ss.n'['VV']'";

    public static final DateTimeFormatter MM_DD_YYYY_HH_MM_SS_NANO_ZONE_FORMATTER = new DateTimeFormatterBuilder()
        .appendPattern("MM/dd/yyyy HH:mm:ss")
        .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
        .appendLiteral(' ')
        .appendZoneId()
        .toFormatter();

    // make sure this goes after the statics
    public static final MriUtilsInstant INSTANCE = new MriUtilsInstant();

    /**
     * A large list of DateTimeFormatters that are used in sequence to attempt to parse a String as a LocalDate
     */
    private final List
<DateTimeFormatter> sequencedFormatters;

    private MriUtilsInstant() {
        sequencedFormatters = new ArrayList<>();
        sequencedFormatters.add(ISO_INSTANT);
        sequencedFormatters.add(MM_DD_YYYY_HH_MM_SS_NANO_ZONE_FORMATTER);
        sequencedFormatters.addAll(MriUtilsLocalDateTime.INSTANCE.sequencedFormatters);
    }

    /**
     * Expect this to be typed in by a user who will inspect the results.
     * So do our best to find a result, but there is a possibility it will be wrong.
     * Not all formats require a TimeZone, as sometimes the TZ will be in the String.
     */
    public Instant parseFlexibleInstant(CharSequence text, @Nullable TimeZone defaultTimeZone) {
        TemporalAccessor temporal = MriUtilsTimePriv.parseFormatterSequenceBest(sequencedFormatters, text);
        return MriUtilsTime.instantFromTemporal(temporal, defaultTimeZone);
    }
}

    public static @Nullable Instant instantFromTemporal(@Nullable TemporalAccessor temporal, @Nullable TimeZone defaultTimeZone) {
        return switch (temporal) {
            case Instant inst -> inst;
            case ZonedDateTime zoned -> zoned.toInstant();
            case LocalDateTime local -> localDateTimeToInstant(local, defaultTimeZone);
            case LocalDate localDate -> localDateTimeToInstant(localDate.atStartOfDay(), defaultTimeZone);
            case LocalTime localTime -> localDateTimeToInstant(localTime.atDate(LocalDate.now()), defaultTimeZone);
            case null -> null;
            default -> throw new IllegalArgumentException("Unexpected class= " + temporal.getClass() + " value: " + temporal);
        };
    }

    public static @Nullable Instant localDateTimeToInstant(@Nullable LocalDateTime localDateTime, @Nullable TimeZone tz) {
        if (localDateTime == null || tz == null) {
            return null;
        }
        return localDateTime.atZone(tz.toZoneId()).toInstant();
    }
}

class MriUtilsTimePriv {

    private static Logger LOG = org.apache.logging.log4j.LogManager.getLogger(MriUtilsTimePriv.class);

    public static TemporalAccessor parseFormatterSequenceBest(List
<DateTimeFormatter> formatters, CharSequence text, TemporalQuery<?>... queries) {
        for (DateTimeFormatter formatter : formatters) {
            try {
                return formatter.parseBest(text, queries);
            } catch (DateTimeParseException ignore) {
                // noop
                LOG.trace("ignore: text={} formatter={}", text, formatter, ignore);
            } catch (Exception e) {
                LOG.warn("Ignoring exception text={} formatter={} ex={}", text, formatter, e);
            }
        }
        throw MriException.from("Could not parse temporal = " + text);
    }

    public static TemporalAccessor parseFormatterSequenceBest(List
<DateTimeFormatter> formatters, CharSequence text) {
        return parseFormatterSequenceBest(formatters, text, Instant::from, LocalDateTime::from, LocalDate::from, LocalTime::from);
    }

    public static 
<T> T parseFormatterSequence(List<DateTimeFormatter> formatters, CharSequence text, TemporalQuery<T> query) {
        for (DateTimeFormatter formatter : formatters) {
            try {
                return formatter.parse(text, query);
            } catch (DateTimeParseException ignore) {
                // noop
                LOG.trace("ignore: text={} formatter={}", text, formatter, ignore);
            } catch (Exception e) {
                LOG.warn("Ignoring exception text={} formatter={}", text, formatter);
            }
        }
        throw MriException.from("Could not parse temporal = " + text);
    }

}

I’m not going to show the JUnit test classes because of the amount of code. (And this doesn’t dseserve its own github repository.) However, I am pleased with the results so far. Currently with a few thousand tests (can easily add many thousands more), no parse failures, and the only errors are caused by greedy/ambiguous parsing, which I don’t think I can fix.

2026_07_19_13_47_42_
2026_07_19_13_51_07_

Java DataTimeFormatter appendOptional issue

I want a date/time/instant parser that “just works” for anything the user is typing in. An AI-solution that analyzed a sentence and did its best would be great, but I am not ready for that amount of work. I could write my own custom antrl parser. But really, the DataTimeFormatter should do the job.

One solution is to create a list of DataTimeFormatters and just try them one at a time. This has some inefficiency.

But DataTimeFormatter has something just for that: The .appendOptional method.

The quickest way for an expert user to type a date is without punctuation. For example, “5 12 26” (using month day year format), Or even shorter: “20260512”. I use the latter a lot because it sorts nicely.

But we also want to handle punctuation between values. A space, a comma, a dash, or a slash. It would be great if DataTimeFormatter had an appendLiteral(“[ ,-/]”) (borrowing regex notation), but it does not. It can support that, but you have to work with .optionalStart and .optionalEnd, which is a little more complicated. Still doable, though.

I have some basic cartesianProduct code, so I wrote it longhand. This is what I came up with:

/**
 * A large list of DateTimeFormatters that are used in sequence to attempt to parse a String
 */
private static final List
<DateTimeFormatter> sequencedLocalDateFormatters;
public static final DateTimeFormatter TRY_ALL_LOCAL_DATE_FORMATTER;
static {
    sequencedLocalDateFormatters = new ArrayList<>();
    sequencedLocalDateFormatters.add(ISO_DATE_FORMATTER);
    sequencedLocalDateFormatters.add(formatter(YYYYMMDD));
    List
<Object> years = List.of("yyyy", "yy");
    List
<Object> months = List.of("MMMM", "MMM", "MM");
    List
<Object> days = List.of("dd");
    List
<Object> separators = List.of(" ", "-", "/", ",");
    List<List<Object>> sets = List.of(years, months, days, separators);
    List<List<Object>> cartesian = cartesianProduct(sets);
    for (List
<Object> list : cartesian) {
        String year = (String) list.get(0);
        String month = (String) list.get(1);
        String day = (String) list.get(2);
        String sep = (String) list.get(3);
        sequencedLocalDateFormatters.add(DateTimeFormatter.ofPattern(year + sep + month + sep + day));
        sequencedLocalDateFormatters.add(DateTimeFormatter.ofPattern(month + sep + day + sep + year));
    }
    DateTimeFormatterBuilder tryAllFormatter = new DateTimeFormatterBuilder();
    for (DateTimeFormatter dateTimeFormatter : sequencedLocalDateFormatters) {
        tryAllFormatter = tryAllFormatter.appendOptional(dateTimeFormatter);
    }
    TRY_ALL_LOCAL_DATE_FORMATTER = tryAllFormatter.toFormatter();
}
public static LocalDate parseLocalDate(CharSequence text) {
    return LocalDate.parse(text, TRY_ALL_LOCAL_DATE_FORMATTER);
}

/**
 * Cartesian product
 * https://www.baeldung.com/java-cartesian-product-sets
 */
static 
<T> List<List<Object>> cartesianProduct(List<List<Object>> sets) {
    return cartesianProduct(sets,0).collect(Collectors.toList());
 }
static Stream<List<Object>> cartesianProduct(List<List<Object>> sets, int index) {
     if (index == sets.size()) {
         List
<Object> emptyList = new ArrayList<>();
         return Stream.of(emptyList);
     }
     List
<Object> currentSet = sets.get(index);
     return currentSet.stream().flatMap(element -> cartesianProduct(sets, index+1)
       .map(list -> {
           List
<Object> newList = new ArrayList<>(list);
           newList.add(0, element);
           return newList;
       }));
}

//JUnit tests
private static Stream
<Arguments> localDateData() {
    return Stream.of(
        Arguments.of("05 12 1963", LocalDate.of(1963, 5, 12)),
        Arguments.of("05-12-1963", LocalDate.of(1963, 5, 12)),
        Arguments.of("05/12/1963", LocalDate.of(1963, 5, 12)),

        Arguments.of("May 12 1963", LocalDate.of(1963, 5, 12)),
        Arguments.of("August 12 1963", LocalDate.of(1963, 8, 12)),

        Arguments.of("05 12 63", LocalDate.of(2063, 5, 12)),
        Arguments.of("05-12-63", LocalDate.of(2063, 5, 12)),
        Arguments.of("05/12/63", LocalDate.of(2063, 5, 12)),

        Arguments.of("May 12 63", LocalDate.of(2063, 5, 12)),
        Arguments.of("August 12 63", LocalDate.of(2063, 8, 12)),

        Arguments.of("19630512", LocalDate.of(1963, 5, 12)),
        Arguments.of("1963-05-12", LocalDate.of(1963, 5, 12)),
        Arguments.of("1963/05/12", LocalDate.of(1963, 5, 12)));
}

@ParameterizedTest
@MethodSource("localDateData")
void test1(String str, LocalDate expectedDate) {
    LocalDate date = LocalDate.parse.(str, TRY_ALL_LOCAL_DATE_FORMATTER);
    assertThat(date).isEqualTo(expectedDate);
}

But when I ran the unit tests, there were errors:

JUnit results

It should have worked. Here is the gemini suggestion for testing the case that errored.

@Test
// gemini suggestion
void geminiSuggestion() {
    String dateString = "05 12 63";

    // Use MM for month, dd for day, and yy for 2-digit year
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM dd yy");
    LocalDate date = LocalDate.parse(dateString, formatter);

    System.out.println(date); // Outputs: 2063-05-12
}

And I am certain that “MM dd yy” is in the list.

Seems like a java bug to me. If I give the DateTimeFormatter a list of DateTimeFormatters to try, it should recover from an individual error and try them all.

So I switched to the “long-hand” version:

public static LocalDate parseLocalDate(CharSequence text) {
    //return LocalDate.parse(text, TRY_ALL_LOCAL_DATE_FORMATTER);
    for (DateTimeFormatter dateTimeFormatter : sequencedLocalDateFormatters) {
        try {
            return LocalDate.parse(text, dateTimeFormatter);
        } catch (Exception ignore) {}
    }
    throw new RuntimeException.from("Could not parse LocalDate = ", text);
}

And this time all the JUnit tests worked!

Bit of a shock. C’est la IT guerre.

Continued at http://www.clevercaboose.com/2026/07/19/more-on-javas-da…flexible-parsing/