← Back to Blog

Fix Flutter gen-l10n Ignoring Per-Locale Date Formats

flutterl10ngen-l10narbdateformatintl

Fix Flutter gen-l10n Ignoring Per-Locale Date Formats

You defined a custom format on a DateTime placeholder in app_de.arb — say dd.MM.yyyy — ran flutter gen-l10n, and your German users still see MM/dd/yyyy. French, Polish, Dutch: same story. Every locale renders the English template's date order.

This is the bug behind flutter/flutter#116716 and #162246, and most answers in those threads stop at "just use intl manually." Here is the actual root cause, which Flutter version fixed it, and three working fixes with copy-paste ARB and Dart code — plus a test that catches the regression if it ever comes back.

The symptom

Your ARB files look correct:

// app_en.arb
{
  "orderDate": "Ordered on {date}",
  "@orderDate": {
    "placeholders": {
      "date": {
        "type": "DateTime",
        "format": "MM/dd/yyyy",
        "isCustomDateFormat": "true"
      }
    }
  }
}
// app_de.arb
{
  "orderDate": "Bestellt am {date}",
  "@orderDate": {
    "placeholders": {
      "date": {
        "type": "DateTime",
        "format": "dd.MM.yyyy",
        "isCustomDateFormat": "true"
      }
    }
  }
}

But the generated app_localizations_de.dart uses the English pattern:

@override
String orderDate(DateTime date) {
  // Expected 'dd.MM.yyyy' — got the template's format instead:
  final intl.DateFormat dateDateFormat =
      intl.DateFormat('MM/dd/yyyy', localeName);
  final String dateString = dateDateFormat.format(date);
  return 'Bestellt am $dateString';
}

The root cause: only the template ARB's placeholder metadata was read

gen-l10n builds its model of every message — including placeholder type, format, and isCustomDateFormat — from the template ARB only (the file named by template-arb-file in l10n.yaml, usually app_en.arb). In Flutter 3.27 and earlier, the @-metadata you wrote in app_de.arb was parsed and then silently discarded. No warning, no error. The German translation string was used, but the German placeholder format never was — so every locale inherited the template's MM/dd/yyyy instead of dd.MM.yyyy.

This was fixed in PR #153459, which makes the generator prefer placeholder definitions from the current locale's ARB over the template's. It shipped in Flutter 3.29 stable (February 2025). Both GitHub issues are now closed, but the threads are locked and the fix version is buried in the comments — which is why so many people still land there.

So the first question is: which Flutter are you on?

  • Flutter 3.29 or newer: per-locale formats work — see Fix 3. If yours still doesn't, check the gotchas listed there.
  • Pinned below 3.29 (enterprise SDK mirrors, old CI images): use Fix 1 or Fix 2, which work on every Flutter version.

Fix 1: Skinny placeholders + DateFormat with localeName

Works everywhere, on any Flutter version. Strip the format from the ARB entirely so gen-l10n generates a method that takes a plain String, and do the formatting yourself with the current locale:

// app_en.arb — note: no "format", type is String
{
  "orderDate": "Ordered on {date}",
  "@orderDate": {
    "placeholders": {
      "date": { "type": "String" }
    }
  }
}
import 'package:intl/intl.dart';

final l10n = AppLocalizations.of(context)!;
// localeName is 'de', 'fr', 'en'... — DateFormat localizes accordingly
final formatted = DateFormat.yMd(l10n.localeName).format(order.createdAt);
Text(l10n.orderDate(formatted));

The key detail the GitHub threads' vague "use intl manually" advice omits: pass l10n.localeName, not Intl.defaultLocale or nothing. localeName is a field on the generated AppLocalizations class and always matches the locale the strings were resolved for, so date order and translation can never disagree.

Downside: formatting logic leaks out of your ARB files into call sites. Fine for one or two dates; tedious for twenty.

Fix 2: Skeleton formats — one template entry, correct order in every locale

Here's the fix most people miss: you usually don't need per-locale custom patterns at all. If your format value is one of DateFormat's named skeleton constructors (yMd, yMMMd, yMMMMEEEEd, Hm, jm, …), intl resolves the pattern itself per locale from CLDR data. You define it once in the template, drop isCustomDateFormat, and every locale renders its own date order:

// app_en.arb — the ONLY file that needs placeholder metadata
{
  "orderDate": "Ordered on {date}",
  "@orderDate": {
    "placeholders": {
      "date": { "type": "DateTime", "format": "yMd" }
    }
  }
}

The generated code calls intl.DateFormat.yMd(localeName), and for DateTime(2026, 3, 14) you get:

Locale Output
en 3/14/2026
de 14.3.2026
fr 14/03/2026

That's M/d/y, d.M.y, and dd/MM/y — correct separators and correct day/month order, with zero per-locale ARB metadata. Reach for a hardcoded pattern like dd.MM.yyyy only when a designer genuinely mandates fixed zero-padding; for "show the date the way this locale expects," skeletons are the right tool and immune to this whole bug class.

Fix 3: Per-locale custom formats (Flutter 3.29+)

On Flutter 3.29 or newer, the ARB pair from the top of this post just works: declare the full placeholder metadata in each locale file, and the generator emits DateFormat('dd.MM.yyyy', localeName) in app_localizations_de.dart. If you're on a modern SDK and still seeing English-only formats, check these gotchas:

  • Redeclare the complete placeholder in the locale ARB — type, format, and isCustomDateFormat — not just format.
  • type must match the template exactly. Since 3.29, a mismatch (e.g. DateTime vs String) is a hard codegen error rather than a silent fallback.
  • isCustomDateFormat historically had to be the string "true", not a bare boolean (#153420). Recent versions accept both; the string form works everywhere.
  • Regenerate: flutter gen-l10n output can be stale after ARB edits — run it (or a full flutter run) and check the generated app_localizations_de.dart, not just hot reload.

One process warning: this fix means placeholder metadata now lives in every ARB file, and a translator deleting an @-block silently reverts that locale to the template format. That's exactly the kind of cross-file drift that's brutal to spot in raw JSON — an ARB editor that shows each key's metadata across all locales side by side makes it visible at review time instead of in production.

A test that catches the regression

Whichever fix you pick, lock it in. Use a day greater than 12 so a swapped month/day order fails loudly instead of passing by luck:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/l10n/app_localizations.dart';

void main() {
  // Day 14 > 12: if month/day order is swapped, formatting can't hide it.
  final date = DateTime(2026, 3, 14);

  test('each locale renders its own date order', () async {
    final de = await AppLocalizations.delegate.load(const Locale('de'));
    final fr = await AppLocalizations.delegate.load(const Locale('fr'));
    final en = await AppLocalizations.delegate.load(const Locale('en'));

    expect(de.orderDate(date), contains('14.3.2026')); // or '14.03.2026' with a custom pattern
    expect(fr.orderDate(date), contains('14/03/2026'));
    expect(en.orderDate(date), contains('3/14/2026'));
  });
}

Loading each delegate directly keeps the test widget-free and fast, and it exercises the generated code — so it fails the moment a Flutter upgrade, an ARB edit, or a template restructure regresses your per-locale formats.

Keep your ARB files honest across locales

Date formats are one of several things that quietly diverge between locale files — plural categories are another (Polish and Russian need few/many that English never shows you). FlutterLocalisation is a translation-management platform built for Flutter's ARB workflow: edit app_<locale>.arb files in a real editor instead of raw JSON, manage translations across all your locales, and get ICU plural-syntax validation that flags a locale missing a plural category its language requires. There's a free tier, and more Flutter i18n deep-dives like this one on the blog.

Try FlutterLocalisation free and stop debugging your locales in raw JSON.