← Back to Blog

Fix gen-l10n DateTime placeholder format errors in ARB

fluttergen-l10narbdatetimeintllocalization

Fix gen-l10n DateTime placeholder format errors in ARB

You add a date to an ARB string, set the placeholder type to DateTime, give it the pattern your designer asked for, and flutter gen-l10n stops with something like:

For the message "orderShipped" the date format "dd/MM/yyyy" for placeholder date
contains at least one invalid date format in locale "en".
Check the intl library's DateFormat class constructors for allowed date formats,
or set "isCustomDateFormat" attribute to "true".

Older Flutter versions word it as does not have a corresponding DateFormat constructor in locale "en". Same cause, same fix. This post explains what the generator is actually checking, shows the Dart it emits for each option, and covers the related complaint of US-style M/D/Y dates showing up in every locale.

Why gen-l10n rejects your pattern

The format attribute of a DateTime placeholder is not a free-form pattern. gen-l10n turns it into a call to a named DateFormat constructor from the intl package. yMd becomes intl.DateFormat.yMd(localeName), Hm becomes intl.DateFormat.Hm(localeName), and so on. If no constructor with that name exists, the generated file would not compile, so the tool validates the name first and refuses to proceed.

The accepted names are the skeleton constructors on DateFormat: d, E, EEEE, LLL, LLLL, M, Md, MEd, MMM, MMMd, MMMEd, MMMM, MMMMd, MMMMEEEEd, QQQ, QQQQ, y, yM, yMd, yMEd, yMMM, yMMMd, yMMMEd, yMMMM, yMMMMd, yMMMMEEEEd, yQQQ, yQQQQ, H, Hm, Hms, j, jm, jms, jmv, jmz, jv, jz, m, ms, s.

So dd/MM/yyyy and EEEE, d MMM fail validation because they are ICU patterns, not constructor names. Leaving format out entirely fails too, with a message saying the attribute needs to be set to determine which DateFormat to use.

Path 1: use a named skeleton and let intl localize it

This is the right default. A skeleton describes which fields you want (year, abbreviated month, day), and intl picks the order, separators and month names for the active locale.

{
  "@@locale": "en",
  "orderShipped": "Your order shipped on {date}",
  "@orderShipped": {
    "description": "Shown on the order detail page",
    "placeholders": {
      "date": {
        "type": "DateTime",
        "format": "yMMMd"
      }
    }
  }
}

gen-l10n emits this into app_localizations_en.dart:

@override
String orderShipped(DateTime date) {
  final intl.DateFormat dateDateFormat = intl.DateFormat.yMMMd(localeName);
  final String dateString = dateDateFormat.format(date);

  return 'Your order shipped on $dateString';
}

Note localeName. The generated class passes the locale of the AppLocalizations instance, so the same DateTime renders as Jul 9, 1959 in en, 9 juil. 1959 in fr, and 9 июл. 1959 г. in ru. The Flutter docs give the equivalent example for yMd: 7/9/1959 in US English and 9.07.1959 in Russian.

Mapping common requests to skeletons:

You wanted Use en_US de
dd/MM/yyyy yMd 7/9/1959 9.7.1959
d MMM yyyy yMMMd Jul 9, 1959 9. Juli 1959
EEEE, d MMM MMMEd Thu, Jul 9 Do., 9. Juli
HH:mm Hm 14:05 14:05
h:mm a jm 2:05 PM 14:05

The j skeletons are worth knowing: j means "hour in the locale's preferred clock", so jm gives 12-hour time with AM/PM in the US and 24-hour time in Germany without you doing anything.

Combining date and time with +

One placeholder can chain skeletons. Join them with + and gen-l10n emits the matching add_ calls:

"date": {
  "type": "DateTime",
  "format": "yMMMd+jm"
}
final intl.DateFormat dateDateFormat =
    intl.DateFormat.yMMMd(localeName).add_jm();

Every part must still be a valid skeleton name, otherwise you get the same validation error.

Path 2: isCustomDateFormat for a fixed pattern

If the product genuinely requires one exact pattern everywhere (an invoice number that embeds yyyyMMdd, a log timestamp, a format mandated by a regulator), set isCustomDateFormat to true. The value can be a JSON boolean or the string "true".

"invoiceStamp": "Invoice generated {date}",
"@invoiceStamp": {
  "placeholders": {
    "date": {
      "type": "DateTime",
      "format": "yyyy-MM-dd HH:mm",
      "isCustomDateFormat": true
    }
  }
}

Generated code:

@override
String invoiceStamp(DateTime date) {
  final intl.DateFormat dateDateFormat =
      intl.DateFormat('yyyy-MM-dd HH:mm', localeName);
  final String dateString = dateDateFormat.format(date);

  return 'Invoice generated $dateString';
}

The locale is still passed, but it only affects symbols: month and weekday names, AM/PM strings, and digits in locales that use non-Latin numerals. The field order and separators are frozen by your pattern. dd/MM/yyyy with isCustomDateFormat prints 09/07/1959 for American users too, which is exactly the kind of thing US testers file bugs about. That is why this flag is the escape hatch, not the default: it silences the validation, and with it the localization.

A middle ground is to keep the pattern in the ARB and let translators change it per locale. Because the template file (usually app_en.arb) is the only one gen-l10n reads placeholder metadata from, you cannot vary format per locale this way. If you need a locale-specific hand-written pattern, use path 3.

Path 3: pass a pre-formatted String

When the date needs logic that an ARB attribute cannot express (relative dates, a pattern that differs per locale, a timezone conversion), format it in Dart and hand the string to a plain String placeholder:

"lastSeen": "Last seen {when}",
"@lastSeen": {
  "placeholders": {
    "when": { "type": "String" }
  }
}
import 'package:intl/intl.dart';

final locale = Localizations.localeOf(context).toString();
final when = DateFormat.yMMMEd(locale).add_jm().format(lastSeenAt);
Text(AppLocalizations.of(context)!.lastSeen(when));

Pass the locale explicitly here as well. The most common reason an app shows M/D/Y in every language is a bare DateFormat.yMd() somewhere in widget code: with no argument, intl falls back to its default locale, which is en_US unless something set Intl.defaultLocale. The Cwtch messenger hit exactly this and fixed it by passing a locale to every constructor.

When you need initializeDateFormatting

If you use flutter_localizations and list GlobalMaterialLocalizations.delegate in localizationsDelegates, the date symbols for your supported locales are loaded for you, and both the generated code and path 3 just work. You only need initializeDateFormatting from package:intl/date_symbol_data_local.dart when intl runs outside that setup: a background isolate, a pure Dart package, a test, or a locale you format for that is not in supportedLocales.

import 'package:intl/date_symbol_data_local.dart';

await initializeDateFormatting('pt_BR');
final s = DateFormat.yMMMd('pt_BR').format(DateTime.now()); // 9 de jul. de 1959

Skip it when it is not needed; calling it in a widget build is a recurring source of jank.

Keep the @ metadata consistent across locales

Two ARB rules trip people up with dates. First, placeholder type, format and isCustomDateFormat come from the template file only. Translators do not need to copy them into app_de.arb, but if they do and the values drift, you get confusing diffs and, in some tools, validation noise. Second, every locale must keep the {date} placeholder name unchanged, or the generated method signature no longer matches the translation.

Editing a dozen app_<locale>.arb files by hand is where those drifts come from. The FlutterLocalisation ARB editor shows each key with its @ metadata next to every locale's translation, so placeholder names stay aligned and the DateTime type and format live in one place. The same editor validates ICU plural syntax per locale, which pairs well with date strings like "{count, plural, ...} on {date}". More walkthroughs like this one are in the Flutter i18n blog, and plans start free on the pricing page.

Checklist

  1. Validation error on a DateTime placeholder: replace the ICU pattern with a skeleton name (yMd, yMMMd, Hm, jm), or chain several with +.
  2. Only set isCustomDateFormat: true when the pattern must be identical in every locale, and accept that it will not reorder day and month.
  3. For anything else, format in Dart with an explicit locale and pass a String.
  4. Never call DateFormat.xxx() without a locale in app code.
  5. Keep placeholder metadata in the template ARB and keep placeholder names identical in every locale file.

Try FlutterLocalisation free and edit your ARB placeholders without hand-editing JSON.