← Back to Blog

Fix Flutter gen-l10n Invalid Date Format with isCustomDateFormat

flutteri18narbgen-l10nintldateformat

Fix Flutter gen-l10n Invalid Date Format with isCustomDateFormat

You had a working date placeholder using "format": "yMd". Design asked for dd/MM/yyyy. You changed one string in app_en.arb, ran the build, and got this:

For the message "invoiceDue" the date format "dd/MM/yyyy" for placeholder
dueDate does not have a corresponding DateFormat constructor in locale "en".
Check the intl library's DateFormat class constructors for allowed date
formats, or set "isCustomDateFormat" attribute to "true".

The fix is two lines. The reason is worth understanding first, because the obvious fix quietly breaks localization for every locale that is not yours.

Why gen-l10n rejects dd/MM/yyyy

The format attribute on a DateTime placeholder is not a date pattern. It is the name of an intl.DateFormat named constructor.

When you write "format": "yMd", gen-l10n emits this into app_localizations_en.dart:

final intl.DateFormat dueDateDateFormat = intl.DateFormat.yMd(localeName);
final String dueDateString = dueDateDateFormat.format(dueDate);

Because it writes DateFormat.<yourValue>(...) literally into generated Dart, an unknown value would be a compile error in code you never wrote. So the tool validates up front against a hardcoded allowlist of 41 constructor names in gen_l10n_types.dart:

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

dd/MM/yyyy is a raw DateFormat pattern, not a constructor name, so it fails the check. That is the whole story behind the "flutter gen-l10n invalid date format" error.

A useful bonus: you can chain allowlisted formats with +. "format": "yMd+jm" generates DateFormat.yMd(localeName).add_jm(), giving you date and time without leaving the safe path.

The two-line fix

Add isCustomDateFormat next to the format:

{
  "invoiceDue": "Payment due {dueDate}",
  "@invoiceDue": {
    "description": "Invoice due date shown on the billing screen",
    "placeholders": {
      "dueDate": {
        "type": "DateTime",
        "format": "dd/MM/yyyy",
        "isCustomDateFormat": "true"
      }
    }
  }
}

Run flutter gen-l10n (or just flutter run, which triggers generation) and the tool now emits the unnamed constructor instead:

final intl.DateFormat dueDateDateFormat = intl.DateFormat('dd/MM/yyyy', localeName);
final String dueDateString = dueDateDateFormat.format(dueDate);

Call site is unchanged:

Text(AppLocalizations.of(context)!.invoiceDue(DateTime.utc(2026, 3, 9)))
// -> Payment due 09/03/2026

Trap 1: it has to be the string "true" on older Flutter

This is the part that costs people an afternoon. Write a real JSON boolean:

"isCustomDateFormat": true

...and on Flutter 3.24 and earlier you get a second, maddening error that reads like it wants exactly what you gave it:

The 'isCustomDateFormat' value of the 'dueDate' placeholder in message
invoiceDue must be a boolean value.

That was flutter/flutter issue #153420, fixed by PR #153439 and shipped in Flutter 3.27.0. Since 3.27 the attribute parser accepts a genuine boolean true/false as well as the strings "true"/"false".

Practical guidance: use the quoted string "true". It works on every Flutter version that has ever supported the attribute, so it survives a CI box pinned to an older SDK and a teammate who has not upgraded. The error message from Flutter itself tells you to set it to "true", quotes included.

Trap 2: no escape hatch for inline ICU syntax

isCustomDateFormat is a placeholder metadata attribute. It does nothing for a date formatted inline inside a select or plural body:

"reminder": "{count, plural, one{Due {d, date, dd/MM/yyyy}} other{...}}"

The message parser validates formatType against the same 41-name allowlist with no override path, so this throws no matter what metadata you attach. Inside plural and select bodies you must use an allowlisted name like yMd. If you need a custom pattern there, hoist the date out into its own message, or format it in Dart and pass the result as a String placeholder.

Trap 3: the locale awareness you just threw away

This is the real cost, and it is silent. DateFormat('dd/MM/yyyy', localeName) is not locale-adaptive. The localeName argument only supplies symbol data (month names, weekday names, digit shapes). The field order and the separators come from your literal pattern and never move.

So after the fix:

Locale "format": "yMd" "dd/MM/yyyy" custom
en-US 3/9/2026 09/03/2026
en-GB 09/03/2026 09/03/2026
de-DE 9.3.2026 09/03/2026
ja-JP 2026/3/9 09/03/2026

An American user reading 09/03/2026 will read it as September 3rd. You have shipped an off-by-six-months bug into a billing screen.

Custom patterns do still localize the symbolic parts. "format": "dd MMMM yyyy" with isCustomDateFormat renders 09 März 2026 in German, because MMMM pulls the month name from locale data. Only the skeleton is frozen.

So: reach for a custom pattern when you need a shape intl does not offer at all (dd.MM. with no year, a fixed yyyy-MM-dd audit stamp, a pattern with literal text). Do not reach for it just to force a separator.

The correct fix: a per-locale pattern table

If you need custom patterns and correct output per language, override the format in each ARB file. Since Flutter 3.27 (PR #153459, issue #153457), gen-l10n reads placeholder format metadata from each locale's own ARB instead of always inheriting the template's. Before 3.27 every locale silently got the template's pattern, which is exactly the "custom date formats always use english locale" complaint in issue #116716.

lib/l10n/app_en.arb (your template):

{
  "@@locale": "en",
  "invoiceDue": "Payment due {dueDate}",
  "@invoiceDue": {
    "placeholders": {
      "dueDate": {
        "type": "DateTime",
        "format": "MM/dd/yyyy",
        "isCustomDateFormat": "true"
      }
    }
  }
}

lib/l10n/app_de.arb:

{
  "@@locale": "de",
  "invoiceDue": "Fällig am {dueDate}",
  "@invoiceDue": {
    "placeholders": {
      "dueDate": {
        "type": "DateTime",
        "format": "dd.MM.yyyy",
        "isCustomDateFormat": "true"
      }
    }
  }
}

lib/l10n/app_ja.arb:

{
  "@@locale": "ja",
  "invoiceDue": "支払期限 {dueDate}",
  "@invoiceDue": {
    "placeholders": {
      "dueDate": {
        "type": "DateTime",
        "format": "yyyy'年'M'月'd'日'",
        "isCustomDateFormat": "true"
      }
    }
  }
}

Note the single quotes in the Japanese pattern. In an intl pattern, any literal character that collides with a format letter must be quoted, or DateFormat will try to interpret it. 'at' in "dd/MM/yyyy 'at' HH:mm" is the common case: unquoted, the a becomes an AM/PM field and the t throws.

If you are on Flutter 3.24 or older and cannot upgrade, the per-locale table will not take effect. Your options are to stick to an allowlisted constructor name like yMd, or format the date in Dart and pass a plain String placeholder.

A note on type

One adjacent error that looks related but is not. If you declare "type": "DateTime" and omit format entirely, gen-l10n throws a different message telling you the format attribute is required to resolve which DateFormat to use. type and format travel together for dates. And if you drop type altogether, the placeholder is generated as Object, your DateTime gets toString()d, and you ship 2026-03-09 00:00:00.000Z to production with no error at all.

Keeping the table honest

A per-locale pattern table is correct but fragile: it lives as duplicated JSON metadata across every ARB file, and nothing in flutter gen-l10n tells you that app_fr.arb still carries the English MM/dd/yyyy you copy-pasted six months ago.

That is where a proper editor beats a text editor. FlutterLocalisation gives you a real ARB editor over your app_<locale>.arb files, so you see every locale's version of a key side by side instead of tab-hopping between raw JSON files. It also runs ICU plural-syntax validation, catching the related class of bug where a translator drops the few or many category that Arabic, Polish, or Russian actually need, something gen-l10n will happily generate around.

More Flutter i18n walkthroughs are on the FlutterLocalisation blog, and pricing starts with a free tier.

Try FlutterLocalisation free and stop editing ARB metadata by hand.