← Back to Blog

Flutter Shows Arabic Numbers Instead of English: The Fix

flutteri18narabicnumberformatintl

Flutter Shows Arabic Numbers Instead of English: The Fix

You add ar to supportedLocales, ship the build, and a tester in Cairo sends a screenshot: the price says ١٬٥٠٠ ر.س. Or the opposite happens, your Arabic UI stubbornly prints 1,500 and the client wants Eastern Arabic numerals. Both are the same bug, and neither is about the language. In Dart's intl package the digit system comes from the exact locale tag string you hand to NumberFormat or DateFormat, and the data behind those tags is narrower than most people assume.

What intl actually ships for Arabic

package:intl does not carry every CLDR locale. Its number data (number_symbols_data.dart) contains exactly three Arabic entries. Here is what each one does in intl 0.20.3, which ships CLDR v48:

Tag you pass Digits 1500 formats as
ar Latin (ZERO_DIGIT: '0') 1,500
ar_EG Arabic-Indic (ZERO_DIGIT: '٠') ١٬٥٠٠
ar_DZ Latin, European separators 1.500

Everything else, ar_SA, ar_AE, ar_JO, ar_MA, ar_QA, is not in the data at all. Intl.verifiedLocale falls back through canonicalisation, then language + region, then language only, so ar_SA resolves to plain ar and gets Latin digits. That is the single fact that explains most of the confusion online: ar_SA and ar_EG behave differently not because Saudi Arabia prefers ASCII digits, but because one tag exists in the bundled data and the other does not.

import 'package:intl/intl.dart';

void main() {
  print(NumberFormat.decimalPattern('ar').format(1500));     // 1,500
  print(NumberFormat.decimalPattern('ar_SA').format(1500));  // 1,500  (falls back to ar)
  print(NumberFormat.decimalPattern('ar_AE').format(1500));  // 1,500  (falls back to ar)
  print(NumberFormat.decimalPattern('ar_EG').format(1500));  // ١٬٥٠٠
  print(NumberFormat.decimalPattern('ar_DZ').format(1500));  // 1.500
  print(NumberFormat.decimalPattern('fa').format(1500));     // ۱٬۵۰۰  (Persian, U+06F0)
  print(NumberFormat.decimalPattern('ur').format(1500));     // 1,500
}

Note ar_EG also swaps the separators: the group separator is the Arabic thousands separator ٬ (U+066C) and the decimal is ٫ (U+066B), not , and .. If you are string-matching on commas anywhere, that breaks too. And note ur: Urdu in intl formats with Latin digits, so "Urdu shows English numbers" is expected behaviour, not a misconfiguration.

One more thing that trips people coming from JavaScript or ICU: intl has no numbering-system override. NumberFormat('#,##0', 'ar-u-nu-arab') does not work the way Intl.NumberFormat does on the web. The unicode extension is not parsed; the tag fails to match, falls back to ar, and you silently get Latin digits.

Why dates and numbers disagreed, and when that changed

For years the classic complaint was that DateFormat('ar') printed ٢٠٢٦/٩/٢١ while NumberFormat('ar') printed 1,500 in the same screen (dart-lang/i18n#477). That was real: the ar entry in date_symbol_data_local.dart used to carry ZERODIGIT: '٠' while the number symbols did not.

That data changed. In intl 0.20.0 the ar date symbols still had the Arabic-Indic zero digit; in 0.20.1, which updated to CLDR v46, it was dropped. So if you bumped intl and your Arabic dates flipped from ٢٠٢٦ to 2026 without a code change, that is where it happened. In 0.20.3 the only Arabic date locale with native digits is ar_EG.

DateFormat does give you an explicit switch that NumberFormat lacks:

// Per instance
final df = DateFormat.yMd('ar_EG')..useNativeDigits = false;
print(df.format(DateTime(2026, 9, 21))); // Latin digits, ar_EG pattern

// Or globally, once at startup, before any DateFormat is built
DateFormat.useNativeDigitsByDefaultFor('ar_EG', false);

There is no NumberFormat.useNativeDigits. For numbers you either pick the tag that has the digits you want, or you convert the output string.

Where the tag comes from in a real Flutter app

Three places decide the tag, and they do not have to agree:

  1. gen-l10n / ARB files. A placeholder declared with "type": "int", "format": "decimalPattern" compiles into intl.NumberFormat.decimalPattern(localeName), where localeName is the locale of the ARB file. app_ar.arb gives ar (Latin digits). Add app_ar_EG.arb and Egyptian users get ١٬٥٠٠, because the generated AppLocalizationsArEg passes 'ar_EG'.
  2. Your own NumberFormat calls. If you pass Localizations.localeOf(context).toString() you get the resolved device locale, ar_EG on an Egyptian phone, ar_SA on a Saudi one, hence different digits on two phones running the same build.
  3. GlobalMaterialLocalizations. Its delegate calls NumberFormat.decimalPattern(localeName) when NumberFormat.localeExists(localeName) is true, and that format backs formatDecimal, the date picker and the time picker. List Locale('ar', 'EG') in supportedLocales and the Material pickers switch to Arabic-Indic digits; list only Locale('ar') and they stay Latin.

Copy-paste helper: one digit system, app-wide

Pick a policy and apply it at the edge of your formatting layer. This converts digits and the Arabic separators in both directions, which is what you need for strings you did not format yourself (server-rendered totals, DateFormat output, pasted input).

/// Digit-system normalisation for Arabic / Persian / Urdu builds.
class Digits {
  static const _arabicIndicZero = 0x0660; // ٠ ar
  static const _extendedZero = 0x06F0;    // ۰ fa, ps, ur-PK fonts
  static const _asciiZero = 0x30;

  static final _nonLatin = RegExp(r'[٠-٩۰-۹]');
  static final _latin = RegExp(r'[0-9]');

  /// ١٬٥٠٠ -> 1,500  ·  ۱۲٫۵ -> 12.5
  static String toLatin(String input) => input
      .replaceAllMapped(_nonLatin, (m) {
        final c = m.group(0)!.codeUnitAt(0);
        final base = c >= _extendedZero ? _extendedZero : _arabicIndicZero;
        return String.fromCharCode(_asciiZero + c - base);
      })
      .replaceAll('٬', ',')  // ٬ thousands
      .replaceAll('٫', '.'); // ٫ decimal

  /// 1,500 -> ١٬٥٠٠ (Arabic-Indic, with Arabic separators)
  static String toArabicIndic(String input) => input
      .replaceAllMapped(_latin, (m) => String.fromCharCode(
          _arabicIndicZero + m.group(0)!.codeUnitAt(0) - _asciiZero))
      .replaceAll(',', '٬')
      .replaceAll('.', '٫');
}

Then one place decides the policy:

enum DigitPolicy { latin, arabicIndic, followLocale }

class AppFormat {
  static DigitPolicy policy = DigitPolicy.latin;

  static String decimal(num value, String locale) =>
      _apply(NumberFormat.decimalPattern(locale).format(value));

  static String currency(num value, String locale, {String? symbol}) =>
      _apply(NumberFormat.currency(locale: locale, symbol: symbol).format(value));

  static String _apply(String s) => switch (policy) {
        DigitPolicy.latin => Digits.toLatin(s),
        DigitPolicy.arabicIndic => Digits.toArabicIndic(s),
        DigitPolicy.followLocale => s,
      };
}

Two caveats worth knowing. The conversion is one UTF-16 code unit per digit, so lengths and selection offsets are preserved. And it rewrites . and , wholesale, so do not feed it a full sentence that also contains ordinary punctuation; run it on formatted number strings only. If you want Arabic-Indic digits for genuinely everything, the cleaner route is to format with 'ar_EG' and skip the converter.

The TextField trap on iOS

Display is only half of it. On iOS, a user with the Arabic keyboard typing into TextInputType.number emits ٠-٩, not 0-9. Two things break at once:

  • FilteringTextInputFormatter.digitsOnly matches [0-9] only, so every keystroke is discarded and the field looks dead. This is flutter/flutter#147257, closed as not planned: it is input data, not a framework bug.
  • int.parse / double.parse accept ASCII digits only and throw FormatException on ١٥٠٠.

Normalise before you filter:

class LatinDigitsFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(TextEditingValue old, TextEditingValue next) {
    final text = Digits.toLatin(next.text);
    return text == next.text
        ? next
        : TextEditingValue(
            text: text,
            selection: next.selection,
            composing: TextRange.empty,
          );
  }
}

TextField(
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    LatinDigitsFormatter(),                 // must come first
    FilteringTextInputFormatter.digitsOnly, // now sees ASCII
  ],
)

Order matters: the formatters run in list order, so the converter has to sit before digitsOnly or there is nothing left to convert. For parsing free-form input, prefer NumberFormat.decimalPattern(locale).tryParse(text) (added in intl 0.19.0) over double.tryParse, since it understands the locale's separators, then fall back to double.tryParse(Digits.toLatin(text)).

A quick checklist before you ship

  • Decide the digit policy per market, not per language. Gulf clients usually want Latin digits in fintech UIs; Egyptian editorial content often wants ١٬٥٠٠.
  • Test on a device set to ar-EG specifically. An ar-SA simulator will never show you the Arabic-Indic path.
  • Grep for double.parse and int.parse on anything a user can type.
  • Keep RTL marks in mind: the ar currency pattern starts with U+200F, so a trimmed or byte-compared price string may not match what you expect.

Keep the ARB side honest

Digits are one half of shipping Arabic; the strings are the other. FlutterLocalisation is an ARB editor and translation manager for app_<locale>.arb files, so adding app_ar_EG.arb next to app_ar.arb is an edit in a UI rather than hand-merged JSON, and its ICU plural validation flags the Arabic categories (zero, one, two, few, many, other) that are easy to drop when you copy an English file. If you are still wiring up the basics, the Flutter intl package guide covers setup and formatting end to end.

Try FlutterLocalisation free and get your Arabic ARB files in order, then let NumberFormat do exactly what you told it to.