Flutter Plural Shows # Instead of the Count: The Fix
Your Polish build says # tygodni where it should say 5 tygodni. Nothing failed: flutter gen-l10n ran clean, the analyzer is quiet, and the English screen looks fine because someone wrote the English strings by hand.
The cause is one character. Standard ICU MessageFormat lets you write # inside a plural branch as shorthand for the count. Flutter's gen-l10n does not implement that shorthand, so the # is copied to the screen as is.
What the broken string looks like
This is valid ICU, and it is what Android, web i18n libraries and most translation platform exports produce:
{
"weeksLeft": "{count, plural, one{# tydzień} few{# tygodnie} many{# tygodni} other{# tygodnia}}",
"@weeksLeft": {
"placeholders": {
"count": { "type": "int" }
}
}
}
Call AppLocalizations.of(context)!.weeksLeft(5) and Flutter picks the right branch (many) and then prints it verbatim: # tygodni.
This is the classic "flutter arb plural # not replaced" bug, and it hits Polish, Russian and Arabic hardest. Those languages have few and many branches that are almost always written by a translator or exported from a tool, both of which use # by default.
Why gen-l10n ignores the ICU
gen-l10n ships its own small message parser in flutter_tools, and it covers a subset of ICU. The lexer has tokens for braces, commas, =, :, numbers, identifiers and the plural and select keywords. Everything else is matched by one catch-all pattern:
RegExp normalString = RegExp(r'[^{}]+');
# is not a brace, so it lands in that bucket as ordinary text. No later step swaps it for the number. As far as gen-l10n is concerned, # tygodni is a nine character literal.
That is also why nothing warns you. The message is syntactically valid, so there is no error to report. Compare that with {count, number}, another common ICU form: gen-l10n rejects it as a syntax error and generation stops for the whole app. That one is loud. The # one ships.
The Flutter docs show the supported form without calling out the difference:
"nWombats": "{count, plural, =0{no wombats} =1{1 wombat} other{{count} wombats}}"
Note the {count} inside other{...}. In Flutter you repeat the placeholder by name in every branch that should show the number. That is the whole rule behind "flutter l10n plural {count} other".
The fix: rewrite # to {count} in every branch
The corrected Polish string:
"weeksLeft": "{count, plural, one{{count} tydzień} few{{count} tygodnie} many{{count} tygodni} other{{count} tygodnia}}"
Doing this by hand across 20 locales is slow and easy to get wrong. A blind find and replace is worse, because it also rewrites strings like Issue #42, and it assumes every plural variable is called count.
The script below walks each message, tracks which plural block it is inside, and replaces # with that block's own variable name. A # outside any plural is left alone. Save it as tool/fix_plural_hash.dart:
import 'dart:convert';
import 'dart:io';
final _pluralStart = RegExp(r'\{\s*([a-zA-Z0-9_]+)\s*,\s*plural\s*,');
/// Replaces every ICU `#` inside a plural branch with `{variable}`.
String rewriteHash(String message) {
final out = StringBuffer();
final stack = <String?>[];
for (var i = 0; i < message.length; i++) {
final ch = message[i];
if (ch == '{') {
stack.add(_pluralStart.matchAsPrefix(message, i)?.group(1));
out.write(ch);
} else if (ch == '}') {
if (stack.isNotEmpty) stack.removeLast();
out.write(ch);
} else if (ch == '#') {
final owner = stack.lastWhere((s) => s != null, orElse: () => null);
out.write(owner == null ? ch : '{$owner}');
} else {
out.write(ch);
}
}
return out.toString();
}
void main(List<String> args) {
final check = args.contains('--check');
final dir = Directory('lib/l10n');
var found = 0;
for (final file in dir.listSync().whereType<File>()) {
if (!file.path.endsWith('.arb')) continue;
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
var changed = false;
for (final key in arb.keys.toList()) {
final value = arb[key];
if (key.startsWith('@') || value is! String) continue;
final fixed = rewriteHash(value);
if (fixed == value) continue;
found++;
changed = true;
stdout.writeln('${file.path}: $key');
arb[key] = fixed;
}
if (changed && !check) {
file.writeAsStringSync(
'${const JsonEncoder.withIndent(' ').convert(arb)}\n',
);
}
}
if (check && found > 0) {
stderr.writeln('$found plural message(s) still use "#".');
exit(1);
}
}
Run it from the project root, then regenerate:
dart run tool/fix_plural_hash.dart
flutter gen-l10n
On the sample above it prints lib/l10n/app_pl.arb: weeksLeft and rewrites all four branches. A message like "Issue #42 for {name}" is untouched. A select nested inside a plural is handled too: the # resolves to the enclosing plural's variable, which is what ICU means by it.
Three things to know before you run it:
- It rewrites the files in place with two space indentation. Key order is kept, but commit first so the diff is easy to review.
- It assumes your ARB files live in
lib/l10n. Change the path if yourarb-dirinl10n.yamlis different. - It does not understand ICU quote escaping or
offset:. If you setuse-escaping: trueand have a deliberately quoted'#'inside a plural, or you rely on plural offsets, review those messages by hand.
Keep the number formatting
In full ICU, # prints the count with locale number formatting, so 12000 becomes 12 000 in Polish. A bare {count} typed as int prints 12000. If you want the grouping back, add a format to the placeholder:
"@weeksLeft": {
"placeholders": {
"count": { "type": "int", "format": "decimalPattern" }
}
}
gen-l10n then formats the value with NumberFormat.decimalPattern for the current locale before inserting it.
Fail the build if a # comes back
Fixing it once is not enough. The next translation import brings the # back. Two guards stop that.
First, the script's check mode exits with code 1 if anything still needs rewriting. Add it to CI before the build:
dart run tool/fix_plural_hash.dart --check
Second, a unit test. It checks the ARB sources and also renders real plurals through the generated classes, so it catches the bug where users would see it. Save it as test/l10n_plural_hash_test.dart:
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import '../tool/fix_plural_hash.dart';
void main() {
test('no ARB plural uses the ICU # shorthand', () {
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb')) continue;
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
arb.forEach((key, value) {
if (key.startsWith('@') || value is! String) return;
expect(rewriteHash(value), value, reason: '${file.path}: $key');
});
}
});
test('rendered plurals never contain #', () {
for (final locale in AppLocalizations.supportedLocales) {
final l10n = lookupAppLocalizations(locale);
for (final n in [0, 1, 2, 3, 5, 11, 12, 22, 100, 101]) {
expect(
l10n.weeksLeft(n),
isNot(contains('#')),
reason: '$locale n=$n',
);
}
}
});
}
Adjust the import to wherever gen-l10n writes your app_localizations.dart, and add one expect per plural message in the second test. lookupAppLocalizations is generated alongside AppLocalizations, so no widget tree is needed.
The sample counts are chosen on purpose. Polish and Russian switch branch at 1, 2 to 4, 5 and up, and again at 22. Arabic uses zero, one, two, few for 3 to 10 and many for 11 to 99. Testing only 1 and 2 would miss the branches most likely to be broken.
Checklist for imported plurals
When you import ICU strings from Android, web or a translation platform export:
- Run the rewrite script so every
#becomes the named placeholder. - Replace
{count, number}with{count}plus aformatin the placeholder metadata. gen-l10n treats the ICUnumberargument as a syntax error. - Check that each locale has the plural categories its language needs. A missing
fewormanyin Polish, Russian or Arabic silently falls back toother. - Run
flutter gen-l10nand the test above.
For step 3, FlutterLocalisation has ICU plural syntax validation built into its ARB editor: it flags locales that are missing a plural category the language actually needs. You also get to edit app_<locale>.arb translations in a UI instead of raw JSON, which makes a wall of nested plural braces much easier to read.
More Flutter i18n guides are on the blog, and plans are on the pricing page.
Try FlutterLocalisation free
If plural bugs keep slipping through review, manage your ARB files somewhere that checks them. Try FlutterLocalisation free and see which of your locales are missing plural categories.