← Back to Blog

Flutter gen-l10n: Per-Locale Date Formats Done Right

fluttergen-l10narbdateformati18n

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:

  1. Whole-object substitution. If app_de.arb defines date, gen-l10n uses that Placeholder object — not a field-by-field merge with the template. In the German ARB above, type is absent. And gen-l10n only emits a DateFormat when placeholder.type == 'DateTime'; otherwise the type falls back to Object and the message interpolates the argument raw. German silently renders Letzte Sicherung: 2026-08-12 00:00:00.000. No warning, no build failure. Every per-locale override must restate type, format, and isCustomDateFormat in full. Flutter's own test for this feature repeats "type": "DateTime" in the non-template locale.
  2. The template owns the name list. The map iterates templatePlaceholders. A placeholder that exists only in app_de.arb, or one whose key is misspelled there, is discarded without a message.
  3. 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 taglanguageCode → 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 restates type, format, and isCustomDateFormat in full.
  • Placeholder names in translated ARBs match the template exactly, or they're ignored.
  • Prefer skeletons; reach for isCustomDateFormat only 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.