← Back to Blog

Fix Flutter showDatePicker "Invalid format" Per Locale

flutteri18nl10ndatepickerintlarb

Fix Flutter showDatePicker "Invalid format" Per Locale

A German user opens your date picker, taps the keyboard icon, types 25.09.2026, and the field turns red: Ungültiges Format. An Arabic user types 25/9/2026 and gets التنسيق غير صالح. The calendar half of the picker is translated perfectly. The text half rejects dates that look completely normal to the person typing them.

Nothing is random here. The picker parses with exactly one pattern per locale, and three things have to agree: which MaterialLocalizations answered, what intl thinks the locale's compact pattern is, and what hint you showed the user. When they disagree, parseCompactDate returns null and you get flutter showdatepicker invalid format. Everything below is verified against Flutter stable 3.47.5 (Dart 3.13) and intl 0.20.3.

What the picker actually parses with

The chain inside InputDatePickerFormField (the widget behind DatePickerEntryMode.input) is short:

  1. _validateDate(text) calls widget.calendarDelegate.parseCompactDate(text, localizations).
  2. GregorianCalendarDelegate forwards straight to localizations.parseCompactDate(inputString).
  3. GlobalMaterialLocalizations implements that with intl:
// flutter_localizations/lib/src/material_localizations.dart
@override
DateTime? parseCompactDate(String? inputString) {
  try {
    return inputString != null ? _compactDateFormat.parseStrict(inputString) : null;
  } on FormatException {
    return null;
  }
}
// _compactDateFormat = intl.DateFormat.yMd(localeName)
  1. null becomes errorFormatText ?? localizations.invalidDateFormatLabel.

So the accepted format is DateFormat.yMd(locale).parseStrict and nothing else. Two separate things break it.

Cause 1: DefaultMaterialLocalizations is answering

If localizationsDelegates does not include GlobalMaterialLocalizations.delegate, MaterialLocalizations.of(context) resolves to DefaultMaterialLocalizations, which is hardcoded US:

// flutter/lib/src/material/material_localizations.dart
String formatCompactDate(DateTime date) {
  // Assumes US mm/dd/yyyy format
  ...
  return '$month/$day/$year';
}
@override
String get dateHelpText => 'mm/dd/yyyy';

The tell: your app is fully translated but the date field hint still reads mm/dd/yyyy. Fix the delegates first, and make sure the locale is in supportedLocales (otherwise resolution falls back and you are back to English patterns):

import 'package:flutter_localizations/flutter_localizations.dart';

MaterialApp(
  localizationsDelegates: const <LocalizationsDelegate<Object>>[
    AppLocalizations.delegate, // your generated ARB delegate
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: const <Locale>[
    Locale('en'),
    Locale('de'),
    Locale('fr'),
    Locale('ar'),
    Locale('es', '419'),
  ],
);

Cause 2: the hint and the parser disagree

Even with the delegates in place, the hint (dateHelpText, shipped by flutter_localizations) and the pattern (DateFormat.yMd, shipped by CLDR data) are two different tables. They do not always match:

Locale DateFormat.yMd pattern dateHelpText hint Typed date that gets rejected
de d.M.y tt.mm.jjjj 25/09/2026
fr dd/MM/y jj/mm/aaaa 25.09.2026
es_419 d/M/y dd/mm/aaaa 25-09-2026
ar d<RLM>/M<RLM>/y + Arabic-Indic digits yyyy/mm/dd 2026/09/25 and 25/9/2026
en_US M/d/y mm/dd/yyyy 25/09/2026

Padding is not the problem: intl's numeric parse is greedy, so 5.9.2026 works against dd.MM.y. Separators are the problem, because parseStrict compares literal fields byte for byte:

// intl/lib/src/intl/date_format_field.dart
void parseLiteral(StringStack input) {
  var found = input.read(width);
  if (found != pattern) {
    throwFormatException(input);
  }
}

Arabic is the worst case and explains every flutter date picker manual entry not working arabic report. The CLDR yMd pattern for ar is d‏/M‏/y with a RIGHT-TO-LEFT MARK (U+200F) baked in after the day and the month, and flutter_localizations' date symbols for ar set ZERODIGIT: '٠', so formatCompactDate renders ٢٥‏/٩‏/٢٠٢٦. A human typing 25/9/2026 misses the invisible marks and the native digits, while the hint tells them year first. Three mismatches, one useless error message.

The fix: a locale-aware CalendarDelegate

Current showDatePicker takes a calendarDelegate parameter, and parseCompactDate, formatCompactDate and dateHelpText are all overridable on it. That is the supported hook: one small subclass fixes parsing, display and hint together.

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

const Set<int> _invisible = <int>{0x200E, 0x200F, 0x061C, 0x200B, 0x00A0};
const Set<String> _separatorChars = <String>{'.', '/', '-'};

class LocaleAwareCalendarDelegate extends GregorianCalendarDelegate {
  LocaleAwareCalendarDelegate(Locale locale)
      : localeName = Intl.canonicalizedLocale(locale.toString());

  final String localeName;

  /// CLDR embeds RTL marks in some patterns (ar is `d<U+200F>/M<U+200F>/y`).
  /// parseStrict matches literals exactly, so a hand-typed date can never win.
  late final String pattern = _strip(DateFormat.yMd(localeName).pattern!);

  late final DateFormat _format = DateFormat(pattern, localeName)
    // Accept and render ASCII digits; the normaliser below folds ٠-٩ and ۰-۹
    // into ASCII, so both sides must agree on one digit set.
    ..useNativeDigits = false;

  late final String _separator =
      pattern.replaceAll(RegExp(r'[yMd]'), '').trim().isEmpty
          ? '/'
          : pattern.replaceAll(RegExp(r'[yMd\s]'), '')[0];

  static String _strip(String s) =>
      String.fromCharCodes(s.codeUnits.where((int c) => !_invisible.contains(c)));

  /// A hint the user can literally type: pattern order, your ARB placeholders.
  String hint({required String day, required String month, required String year}) =>
      pattern.replaceAllMapped(RegExp(r'y+|M+|d+'), (Match m) => switch (m[0]![0]) {
            'y' => year,
            'M' => month,
            _ => day,
          });

  String _normalise(String input) {
    final StringBuffer out = StringBuffer();
    bool lastWasSeparator = false;
    for (final int code in _strip(input.trim()).codeUnits) {
      final String char = String.fromCharCode(code);
      if (code >= 0x0660 && code <= 0x0669) {
        out.writeCharCode(code - 0x0660 + 0x30); // Arabic-Indic
        lastWasSeparator = false;
      } else if (code >= 0x06F0 && code <= 0x06F9) {
        out.writeCharCode(code - 0x06F0 + 0x30); // Extended (fa, ur)
        lastWasSeparator = false;
      } else if (_separatorChars.contains(char)) {
        if (!lastWasSeparator) {
          out.write(_separator);
        }
        lastWasSeparator = true;
      } else {
        out.write(char);
        lastWasSeparator = false;
      }
    }
    return out.toString();
  }

  @override
  String formatCompactDate(DateTime date, MaterialLocalizations localizations) =>
      _format.format(date);

  @override
  DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) {
    if (inputString == null || inputString.trim().isEmpty) {
      return null;
    }
    final String text = _normalise(inputString);
    try {
      final DateTime parsed = _format.parseLoose(text);
      return _componentsMatch(text, parsed) ? dateOnly(parsed) : null;
    } on FormatException {
      return null;
    }
  }

  @override
  String dateHelpText(MaterialLocalizations localizations) =>
      localizations.dateHelpText; // overridden per call via fieldHintText

  /// parseLoose rolls impossible dates over (31.02 becomes 3 March), so check
  /// the numbers the user typed against the date we got back.
  bool _componentsMatch(String text, DateTime parsed) {
    final List<String> order = RegExp(r'y+|M+|d+')
        .allMatches(pattern)
        .map((Match m) => m[0]![0])
        .toList();
    final List<int?> parts =
        text.split(_separator).map((String p) => int.tryParse(p.trim())).toList();
    if (order.length != 3 || parts.length != 3 || parts.contains(null)) {
      return true; // unusual pattern, trust intl
    }
    final Map<String, int> typed = Map<String, int>.fromIterables(
        order, parts.cast<int>());
    return typed['d'] == parsed.day && typed['M'] == parsed.month;
  }
}

parseLoose is the second half of the fix: it tolerates missing and extra whitespace around literals, which matters for patterns like Hungarian y. MM. dd. where nobody types the trailing dot.

Wire the strings up from ARB

Now flutter showdatepicker fieldHintText localization stops being guesswork: the hint is generated from the same pattern the parser uses, and only the placeholder words come from your ARB files.

Future<DateTime?> pickDate(BuildContext context) {
  final AppLocalizations t = AppLocalizations.of(context)!;
  final delegate = LocaleAwareCalendarDelegate(Localizations.localeOf(context));
  final String hint = delegate.hint(
    day: t.datePlaceholderDay,
    month: t.datePlaceholderMonth,
    year: t.datePlaceholderYear,
  );

  return showDatePicker(
    context: context,
    initialDate: DateTime.now(),
    firstDate: DateTime(1900),
    lastDate: DateTime(2100),
    initialEntryMode: DatePickerEntryMode.input,
    calendarDelegate: delegate,
    fieldLabelText: t.dateFieldLabel,
    fieldHintText: hint,
    errorFormatText: t.dateFieldInvalidFormat(hint),
    errorInvalidText: t.dateFieldOutOfRange,
    keyboardType: TextInputType.datetime,
  );
}

app_de.arb:

{
  "dateFieldLabel": "Datum eingeben",
  "datePlaceholderDay": "tt",
  "datePlaceholderMonth": "mm",
  "datePlaceholderYear": "jjjj",
  "dateFieldInvalidFormat": "Ungültiges Format. Beispiel: {example}",
  "@dateFieldInvalidFormat": {
    "placeholders": { "example": { "type": "String" } }
  },
  "dateFieldOutOfRange": "Außerhalb des Zeitraums."
}

For app_ar.arb the placeholders become يوم / شهر / سنة, and because the hint is built from d/M/y, the Arabic user finally sees day first, matching what the parser wants. Keeping these five keys aligned across ten locales is exactly the kind of drift our ARB editor exists to prevent: you see every locale's value for dateFieldInvalidFormat side by side instead of grepping ten JSON files.

Test it once per locale

This is a five-line loop and it catches every regression, including the es-419 versus es split.

import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';

void main() {
  initializeDateFormatting();

  for (final Locale locale in <Locale>[
    const Locale('ar'),
    const Locale('de'),
    const Locale('fr'),
    const Locale('es', '419'),
  ]) {
    test('typed dates parse in $locale', () async {
      final MaterialLocalizations l10n =
          await GlobalMaterialLocalizations.delegate.load(locale);
      final delegate = LocaleAwareCalendarDelegate(locale);
      final DateTime expected = DateTime(2026, 9, 25);

      // What the field shows must be re-parseable.
      expect(delegate.parseCompactDate(
          delegate.formatCompactDate(expected, l10n), l10n), expected);

      // Whatever separator the user reaches for.
      for (final String typed in <String>['25/9/2026', '25.9.2026', '25-09-2026']) {
        expect(delegate.parseCompactDate(typed, l10n), expected, reason: typed);
      }

      // Still rejects nonsense.
      expect(delegate.parseCompactDate('31/2/2026', l10n), isNull);
    });
  }
}

These day-first locales all accept 25/9/2026. Do not add en_US to that loop: there, month 25 does not exist and null is the correct answer.

If your SDK has no calendarDelegate

Older SDKs have no hook on showDatePicker, so you have two options. Ship initialEntryMode: DatePickerEntryMode.calendarOnly to remove the broken text path entirely, or build your own TextFormField with the normaliser above, parse it yourself, and pass the result as initialDate to a calendar-only picker. Flutter issue #150611 (asking for an input-only locale override) is still open, so do not wait on the framework for this.

One last trap for Persian, Urdu and Nepali: those locales set ZERODIGIT, so DateFormat formats and expects native digits. Either keep useNativeDigits = false as above, or call DateFormat.useNativeDigitsByDefaultFor('fa', false) once at startup so the whole app agrees on one digit set.

Keep the strings where translators can see them

The code fix is thirty lines. The part that rots is the five ARB keys across every locale you ship: one missing datePlaceholderDay and a translator's hint silently falls back to English while the parser keeps expecting d.M.y. FlutterLocalisation gives you an ARB editor over your app_<locale>.arb files, translation management across all your locales, and ICU plural validation that flags a locale missing a plural category its language actually needs. More Flutter i18n walkthroughs live on our blog, and the pricing page has the free tier.

Try FlutterLocalisation free and stop shipping date fields your own users cannot type into.