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:
_validateDate(text)callswidget.calendarDelegate.parseCompactDate(text, localizations).GregorianCalendarDelegateforwards straight tolocalizations.parseCompactDate(inputString).GlobalMaterialLocalizationsimplements that withintl:
// 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)
nullbecomeserrorFormatText ?? 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.