Flutter gen-l10n: Per-Locale Date Formats Done Right
You set "format": "dd.MM.yyyy" in app_de.arb, ran flutter gen-l10n, and German users still see 08/12/2026. Or — if you recently upgraded Flutter — they now see something worse: 2026-08-12 00:00:00.000.
Both symptoms come from the same place: how gen-l10n decides which placeholder definition to use for a given locale. The rules changed in Flutter 3.29, and the new rules have a trap that no error message will tell you about. Here is the exact mechanism, then three fixes ranked by how much maintenance they cost you.
The ARB pair that produces the bug
lib/l10n/app_en.arb (the template):
{
"@@locale": "en",
"lastBackup": "Last backup: {date}",
"@lastBackup": {
"description": "Timestamp of the most recent backup",
"placeholders": {
"date": {
"type": "DateTime",
"format": "MM/dd/yyyy",
"isCustomDateFormat": "true"
}
}
}
}
lib/l10n/app_de.arb — looks completely reasonable:
{
"@@locale": "de",
"lastBackup": "Letzte Sicherung: {date}",
"@lastBackup": {
"placeholders": {
"date": {
"format": "dd.MM.yyyy",
"isCustomDateFormat": "true"
}
}
}
}
On Flutter 3.27 and older, that German block was read and thrown away. gen_l10n_types.dart built placeholder metadata from the template bundle only, so every locale's generated code inherited MM/dd/yyyy. That is flutter#116716 ("Custom date formats always use english locale") and flutter#153457 ("date formats and number formats always use template arb") — the same bug also hit NumberFormat.
It is fixed. PR #153459 — "prefer placeholder definitions defined by the current locale rather than the template locale" — merged 14 Nov 2024 and first shipped in stable Flutter 3.29.0 (12 Feb 2025). It missed the 3.27 branch, which is why flutter#162246 was filed against 3.27.3 in January 2025 and closed as already-fixed. Current stable is 3.44.x. So step zero is always:
flutter --version
flutter gen-l10n
grep -n "DateFormat" lib/l10n/app_localizations_de.dart
If that grep shows intl.DateFormat('MM/dd/yyyy', localeName) in the German file, you're on pre-3.29 tooling. Upgrade.
The trap on 3.29+: the override replaces, it does not merge
Here's the part nobody writes down. This is the current lookup, verbatim from packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart:
Iterable<Placeholder> getPlaceholders(LocaleInfo locale) {
final Map<String, Placeholder>? placeholders = localePlaceholders[locale];
if (placeholders == null) {
return templatePlaceholders.values;
}
return templatePlaceholders.values.map(
(Placeholder templatePlaceholder) =>
placeholders[templatePlaceholder.name] ?? templatePlaceholder,
);
}
Three consequences that bite in practice:
- Whole-object substitution. If
app_de.arbdefinesdate, gen-l10n uses thatPlaceholderobject — not a field-by-field merge with the template. In the German ARB above,typeis absent. And gen-l10n only emits aDateFormatwhenplaceholder.type == 'DateTime'; otherwise the type falls back toObjectand the message interpolates the argument raw. German silently rendersLetzte Sicherung: 2026-08-12 00:00:00.000. No warning, no build failure. Every per-locale override must restatetype,format, andisCustomDateFormatin full. Flutter's own test for this feature repeats"type": "DateTime"in the non-template locale. - The template owns the name list. The map iterates
templatePlaceholders. A placeholder that exists only inapp_de.arb, or one whose key is misspelled there, is discarded without a message. - The template owns the signature. Method parameters are always generated from the template, so
String lastBackup(DateTime date)is identical in every locale class. Only the formatting body varies.
Fix 1 (recommended): delete the custom formats, use skeletons
CLDR already knows every locale's date order. Named DateFormat skeletons resolve per locale at runtime, so you need metadata in the template only — no per-locale @ blocks to keep in sync at all.
{
"@@locale": "en",
"lastBackup": "Last backup: {date}",
"@lastBackup": {
"placeholders": {
"date": { "type": "DateTime", "format": "yMd" }
}
}
}
gen-l10n emits intl.DateFormat.yMd(localeName) into every locale class, and localeName is the active locale. With DateTime(2026, 8, 12):
| locale | skeleton yMd |
skeleton yMMMMd |
|---|---|---|
en |
8/12/2026 |
August 12, 2026 |
de |
12.8.2026 |
12. August 2026 |
fr |
12/08/2026 |
12 août 2026 |
ar |
١٢/٨/٢٠٢٦ |
١٢ أغسطس ٢٠٢٦ |
Note German yMd is d.M.y in CLDR — 12.8.2026, not zero-padded 12.08.2026. That's the correct short form for the locale; if a designer insists on padding, that is precisely when you move to fix 2 or 3.
There are 41 valid skeletons (d, Md, yMd, yMMMd, yMMMMd, yMMMMEEEEd, Hm, jms, jmz, …). You can chain them with +, which gen-l10n turns into .add_*():
"date": { "type": "DateTime", "format": "yMd+jm" }
→ intl.DateFormat.yMd(localeName).add_jm(). Anything outside that set requires isCustomDateFormat (accepted as true or "true" on current Flutter), and that's the path you're trying to leave.
Fix 2: pass a pre-formatted String
When you genuinely need per-locale patterns — a legal date format, a fixed-width table column — take the formatting out of ARB entirely. Declare a String placeholder:
"@lastBackup": {
"placeholders": {
"date": { "type": "String", "example": "12.08.2026" }
}
}
Then format at the call site. The generated class exposes localeName, already canonicalized by Intl.canonicalizedLocale — the most reliable locale string you can hand to intl:
import 'package:intl/intl.dart';
import 'package:my_app/l10n/app_localizations.dart';
String shortDate(BuildContext context, DateTime dt) {
final l10n = AppLocalizations.of(context)!;
return DateFormat.yMd(l10n.localeName).format(dt);
}
// Text(AppLocalizations.of(context)!.lastBackup(shortDate(context, backupTime)))
Translators keep owning the sentence; you keep owning the pattern, in Dart, where it's testable.
Fix 3: a locale → pattern map for legacy designs
If a spec really does mandate dd.MM.yyyy for German and MM/dd/yyyy for English, encode it once. Pair this with fix 2's String placeholder:
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
const _legacyDatePatterns = <String, String>{
'en': 'MM/dd/yyyy',
'de': 'dd.MM.yyyy',
'fr': 'dd/MM/yyyy',
'ar': 'dd/MM/yyyy',
};
extension LegacyDates on BuildContext {
String legacyDate(DateTime dt) {
final locale = Localizations.localeOf(this);
final tag = Intl.canonicalizedLocale(locale.toString()); // de_DE, ar_EG…
final pattern = _legacyDatePatterns[tag] ??
_legacyDatePatterns[locale.languageCode] ??
'yyyy-MM-dd';
return DateFormat(pattern, tag).format(dt);
}
}
The tag → languageCode → ISO fallback chain matters: Locale('de', 'AT') resolves to de_AT, misses the map, and still lands on the German pattern instead of an American one. Month and weekday names inside a custom pattern are still localized by intl; only the field order is frozen.
Lock it down with a widget test
This regression is invisible in code review, so assert the rendered string. Pumping a MaterialApp with AppLocalizations.localizationsDelegates loads date symbols for you (GlobalMaterialLocalizations's delegate calls into initializeDateFormattingCustom for every locale flutter_localizations ships) — but if you build the expected value before pumping, initialize explicitly in setUpAll:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:my_app/l10n/app_localizations.dart';
void main() {
setUpAll(() => initializeDateFormatting());
final date = DateTime(2026, 8, 12);
Future<void> pumpIn(WidgetTester tester, Locale locale) {
return tester.pumpWidget(MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) =>
Text(AppLocalizations.of(context)!.lastBackup(date)),
),
));
}
testWidgets('de renders the German short date', (tester) async {
await pumpIn(tester, const Locale('de'));
await tester.pumpAndSettle();
expect(find.text('Letzte Sicherung: 12.8.2026'), findsOneWidget);
});
testWidgets('ar does not leak the template pattern', (tester) async {
await pumpIn(tester, const Locale('ar'));
await tester.pumpAndSettle();
expect(find.textContaining(DateFormat.yMd('ar').format(date)), findsOneWidget);
expect(find.textContaining('8/12/2026'), findsNothing);
});
}
The hardcoded German golden catches the "placeholder lost its type and printed 2026-08-12 00:00:00.000" failure. The findsNothing assertion on Arabic catches template-format leakage. And don't hardcode 12/8/2026 for ar: DateFormat.useNativeDigits defaults to true, so Arabic renders Arabic-Indic digits (١٢) plus RTL marks. Compute the expectation with DateFormat, or call DateFormat.useNativeDigitsByDefaultFor('ar', false) if your product wants Western digits.
Checklist
- On Flutter ≥ 3.29? If not, upgrade — the per-locale metadata bug is a tooling bug, not yours.
- Every per-locale
@-override restatestype,format, andisCustomDateFormatin full. - Placeholder names in translated ARBs match the template exactly, or they're ignored.
- Prefer skeletons; reach for
isCustomDateFormatonly when a real requirement forces it. - A widget test pumps at least one non-Latin, non-English locale.
Hand-editing @-blocks across a dozen app_<locale>.arb files is how these mismatches creep in. FlutterLocalisation's ARB editor lets you work on your ARB files in a UI instead of raw JSON, and its ICU plural validation flags locales missing a plural category their language actually needs — the same class of silent, locale-specific breakage as this one. Related reading: Flutter ARB placeholders: variables, dates and numbers and when dates render in English for every locale.
Try FlutterLocalisation free and keep your ARB files honest across every locale you ship.