Flutter Plurals Wrong in Russian, Polish and Arabic: Find the Missing few/many Categories in Your ARB Files
A Russian user reports "5 файл" or "2 файлов" in your app. Your English build looks fine, flutter gen-l10n prints no warning, and the translation file has a plural in it. The problem is almost always the same: the ARB entry for that locale only has one and other, because it was copied from the English template, and the language needs few and many as well.
This post shows why nothing warns you, which ICU plural categories Russian, Polish and Arabic really need, ARB you can paste, and a test that finds every gap before release.
Why gen-l10n does not complain
The Flutter internationalization docs are explicit: in a plural message only the other case is required. Every other case is optional, for every locale. The generator does not check your message against the plural rules of the language, so a Russian message with only one and other is valid input.
At runtime the generated code calls Intl.pluralLogic from package:intl. That function asks the locale's CLDR rule for a category and then picks the matching string. If you did not supply that string, it falls back. The relevant part of the intl source (0.20.x) looks like this:
switch (pluralCase) {
case PluralCase.ZERO:
return zero ?? other;
case PluralCase.ONE:
return one ?? other;
case PluralCase.TWO:
return two ?? few ?? other;
case PluralCase.FEW:
return few ?? other;
case PluralCase.MANY:
return many ?? other;
case PluralCase.OTHER:
return other;
}
So a gen-l10n plural falls back to other with no error and no log line. English only ever resolves to one or other, which is why the bug never shows up in an English dev build. It only appears when a real user runs the app in Russian with a count of 2, 5 or 12.
The ICU plural categories Flutter uses per language
CLDR defines six categories: zero, one, two, few, many, other. Each language uses a subset. The names are labels, they do not mean what the English words suggest. In Russian one covers 1, 21, 31 and 101, and many covers 0.
Russian (ru): one, few, many, other
one: ends in 1, except 11 (1, 21, 31, 101)few: ends in 2 to 4, except 12 to 14 (2, 3, 4, 22, 23)many: ends in 0 or 5 to 9, plus 11 to 14 (0, 5, 11, 12, 20, 100)other: fractions (1.5)
Polish (pl): one, few, many, other
one: exactly 1few: ends in 2 to 4, except 12 to 14 (2, 3, 4, 22)many: everything else that is a whole number (0, 5, 11, 12, 21, 25, 101)other: fractions
Note the difference from Russian: in Polish 21 is many, not one. A Polish file copied from a Russian one will be wrong in a different way.
Arabic (ar): all six
zero: 0one: 1two: 2few: last two digits 03 to 10 (3, 10, 103)many: last two digits 11 to 99 (11, 26, 111)other: 100, 101, 102, and fractions
This is why "flutter intl plural arabic six forms" is a common search: Arabic is the one widely shipped locale that uses every category.
Copy-paste ARB for each locale
The English template, with the placeholder metadata gen-l10n requires for plural messages:
{
"@@locale": "en",
"fileCount": "{count, plural, one{{count} file} other{{count} files}}",
"@fileCount": {
"description": "Number of files in a folder",
"placeholders": {
"count": { "type": "int" }
}
}
}
Russian, app_ru.arb:
{
"@@locale": "ru",
"fileCount": "{count, plural, one{{count} файл} few{{count} файла} many{{count} файлов} other{{count} файла}}"
}
Polish, app_pl.arb:
{
"@@locale": "pl",
"fileCount": "{count, plural, one{{count} plik} few{{count} pliki} many{{count} plików} other{{count} pliku}}"
}
Arabic, app_ar.arb:
{
"@@locale": "ar",
"fileCount": "{count, plural, zero{لا توجد ملفات} one{ملف واحد} two{ملفان} few{{count} ملفات} many{{count} ملفًا} other{{count} ملف}}"
}
Two things to watch:
- Always print
{count}in the Russianoneform. Writingone{1 файл}shows "1 файл" for 21, 31 and 101 too. - Keep
othereven when the placeholder is anintand the language never reaches it for whole numbers. gen-l10n requires it, and it is the fallback if a category goes missing later.
A Dart test that loops 0 to 111 to expose gaps
You do not need to hard-code the rules above. package:intl already ships them, so the test can ask it which categories a locale produces for the counts 0 to 111, then check that every plural message in the ARB file defines each one. The range matters: 111 is far enough to hit the teens (11 to 14), the twenties (21 to 24) and the Arabic other case at 100 to 102.
Create test/plural_categories_test.dart:
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart';
const locales = ['ru', 'pl', 'ar'];
const exactForms = {'zero': '=0', 'one': '=1', 'two': '=2'};
Set<String> neededCategories(String locale) => {
for (var n = 0; n <= 111; n++)
Intl.pluralLogic(
n,
locale: locale,
zero: 'zero',
one: 'one',
two: 'two',
few: 'few',
many: 'many',
other: 'other',
useExplicitNumberCases: false,
),
};
void main() {
for (final locale in locales) {
test('$locale plurals define every category the language needs', () {
final file = File('lib/l10n/app_$locale.arb');
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
final needed = neededCategories(locale);
final problems = <String>[];
arb.forEach((key, value) {
if (key.startsWith('@') || value is! String) return;
if (!RegExp(r',\s*plural\s*,').hasMatch(value)) return;
for (final category in needed) {
final named = RegExp(r'\b' + category + r'\s*\{').hasMatch(value);
final exact = exactForms[category];
final numeric = exact != null && value.contains('$exact{');
if (!named && !numeric) {
problems.add('$key is missing "$category"');
}
}
});
expect(problems, isEmpty, reason: problems.join('\n'));
});
}
}
useExplicitNumberCases: false is important. By default pluralLogic returns the zero, one or two string for the exact counts 0, 1 and 2 in any locale, which would make the test think Russian needs zero and two. Turning it off gives you the pure CLDR answer.
Run it with flutter test test/plural_categories_test.dart. A Russian file that was copied from English fails like this:
fileCount is missing "few"
fileCount is missing "many"
Adjust the lib/l10n path if your l10n.yaml sets a different arb-dir, and add every locale you ship to the list. Czech, Ukrainian, Lithuanian, Welsh and Hebrew all have categories beyond one and other.
See the wrong output yourself
If you want to see what users see, print the generated strings. gen-l10n generates a lookupAppLocalizations function next to your AppLocalizations class:
final l10n = lookupAppLocalizations(const Locale('ru'));
for (final n in [1, 2, 5, 11, 21, 22, 25, 111]) {
print(l10n.fileCount(n));
}
With only one and other in the Russian ARB, 2, 5, 11, 22, 25 and 111 all print the same other form. With all four categories you get файл, файла, файлов in the right places.
Catch missing categories before release
Three checks, from cheapest to most thorough.
Run the test in CI. The test above reads the ARB files directly, so it runs in a plain flutter test step and fails the build the moment a translator delivers a file with a dropped category.
Do not rely on untranslated-messages-file. That l10n.yaml option lists keys that are missing from a locale. A plural message that exists but lacks few counts as translated, so it never appears in that report.
Validate while editing. Most of these bugs are introduced when someone edits raw JSON, or when a translation tool flattens a four-form plural back to two. The FlutterLocalisation ARB editor includes ICU plural-syntax validation that flags a locale missing a plural category the language actually needs, for example a dropped few or many in Arabic, Polish or Russian. The gap is visible while the translation is being written, not after a user files a bug.
Quick checklist
- Russian and Polish plurals have
one,few,manyandother. - Arabic plurals have
zero,one,two,few,manyandother. - The Russian
oneform prints{count}, never a literal 1. - Polish was translated as Polish, not adapted from Russian (21 differs).
- The plural category test runs in CI for every locale you ship.
Try FlutterLocalisation free
If you manage more than a couple of locales, checking plural forms by eye in JSON does not scale. FlutterLocalisation gives you an ARB editor for your app_<locale>.arb files, translation management across locales, and plural validation that points at the exact locale missing a category. There is a free tier, so you can load your existing ARB files and see what it flags. See pricing, browse more Flutter i18n tutorials on the blog, or try FlutterLocalisation free.