Fix Flutter NumberFormat.currency: Wrong Currency & Symbol
You ship a subscription app to Portugal, format a price with NumberFormat.currency(locale: 'pt_PT'), and it prints R$ 9,99 — Brazilian Reais — for a customer paying in Euros. Or you pass a currency name and get EUR 9.99 instead of €9.99. Both are real, currently-shipping behaviors in Dart's intl package, and both come from the same root misconception:
A locale is not a currency.
ptdoesn't mean "Euro,"endoesn't mean "USD," and letting the formatter guess the currency from the language is where multi-region apps break.
Here's exactly why it happens and a reusable Money formatter that never guesses.
Why NumberFormat.currency(locale: 'pt') defaults to BRL
When you don't pass a name, intl looks up a default currency for the locale from CLDR data. For a language-only locale like pt, CLDR resolves the default region to Brazil (the largest pt population), so the default currency is BRL. That's technically "correct" per the data — it's just never what a European app wants.
Worse, there's a documented bug where even the fully-qualified pt_PT locale falls back to BRL instead of EUR (flutter/flutter#146073). The country code gets dropped during resolution and you land back on the language default.
import 'package:intl/intl.dart';
// ❌ Currency inferred from locale — wrong for Portugal
NumberFormat.currency(locale: 'pt').format(9.99); // 'R$ 9,99' (BRL!)
NumberFormat.currency(locale: 'pt_PT').format(9.99); // often 'R$ 9,99' too
// Same trap in other language/region combos
NumberFormat.currency(locale: 'en_DE').format(9.99); // may fall back to '$' not '€'
The fix is never to rely on that inference. Always pass the currency explicitly.
The other bug: 'EUR' instead of '€'
Starting around intl 0.19.0, NumberFormat.currency regressed so that passing only a name returns the ISO 4217 code as the symbol — you get EUR 9.99 or USD 9.99 instead of €9.99 / $9.99 (dart-lang/i18n#826).
// ❌ ISO code leaks into the UI on affected intl versions
NumberFormat.currency(name: 'EUR').format(9.99); // 'EUR 9.99'
// ✅ Force the glyph — this is immune to the regression
NumberFormat.currency(name: 'EUR', symbol: '€').format(9.99); // '€9.99'
The lesson: when you care about the glyph, pass symbol yourself. Don't leave it to a lookup that has shipped broken.
Decision table: name vs symbol vs simpleCurrency vs decimalDigits
These four levers do different jobs. Mixing them up is most of the confusion:
| Lever | Controls | Reach for it when… |
|---|---|---|
locale: |
Digit grouping & decimal separators (1.234,56 vs 1,234.56) and pattern position |
You want numbers grouped the way the reader expects. Not for choosing the currency. |
name: (ISO 4217) |
The currency identity ('EUR', 'JPY') and its default decimal digits |
Always — it's the source of truth, and helps screen readers / parsing. |
symbol: |
The displayed glyph ('€', 'R$', 'kr') |
Whenever you want a real symbol and not an ISO code. Overrides the buggy lookup. |
simpleCurrency: (constructor) |
Auto-picks the simple symbol for name if unambiguous |
Quick internal tools where you trust the default symbol. Avoid in production multi-currency UIs. |
decimalDigits: |
How many fraction digits are shown | JPY/KRW need 0, most need 2, BHD/KWD need 3. Override when the default is wrong. |
NumberFormat.simpleCurrency(locale: 'pt', name: 'EUR') is the convenience path — it looks up € for you — but it gives you no control when the lookup is wrong, and it still infers the currency from locale if you omit name. For anything customer-facing, prefer the explicit currency constructor.
About decimalDigits
If you don't set decimalDigits, intl uses the currency's standard: 2 for EUR/USD/BRL, 0 for JPY and KRW, 3 for Kuwaiti Dinar. Get this wrong and you'll render ¥1,050.00 (nonsense — yen has no cents) or truncate KWD amounts.
NumberFormat.currency(locale: 'ja', name: 'JPY', symbol: '¥').format(1050); // '¥1,050'
NumberFormat.currency(locale: 'en', name: 'JPY', symbol: '¥',
decimalDigits: 2).format(1050); // '¥1,050.00' ← wrong for yen
A reusable Money formatter that decouples locale from currency
The design goal: display locale (how digits look to this reader) and currency (what they're actually paying) are two independent inputs. Never derive one from the other.
import 'package:intl/intl.dart';
/// One row per currency you actually charge in.
class CurrencySpec {
const CurrencySpec(this.code, this.symbol, {this.decimalDigits = 2});
final String code; // ISO 4217, e.g. 'EUR'
final String symbol; // glyph, e.g. '€'
final int decimalDigits; // 0 for JPY/KRW, 3 for KWD/BHD
}
const _currencies = <String, CurrencySpec>{
'EUR': CurrencySpec('EUR', '€'),
'BRL': CurrencySpec('BRL', 'R\$'),
'USD': CurrencySpec('USD', '\$'),
'JPY': CurrencySpec('JPY', '¥', decimalDigits: 0),
'KWD': CurrencySpec('KWD', 'د.ك', decimalDigits: 3),
};
class Money {
/// [displayLocale] shapes grouping/separators; [currencyCode] is the
/// real currency being charged — the two never influence each other.
Money({required this.currencyCode, this.displayLocale});
final String currencyCode;
final String? displayLocale;
late final CurrencySpec _spec =
_currencies[currencyCode] ?? CurrencySpec(currencyCode, currencyCode);
late final NumberFormat _fmt = NumberFormat.currency(
locale: displayLocale, // reader's number style
name: _spec.code, // correct ISO identity
symbol: _spec.symbol, // force glyph → dodges the ISO regression
decimalDigits: _spec.decimalDigits,
);
String format(num amount) => _fmt.format(amount);
}
Now a Portuguese reader charged in Euros gets Euro-correct output with Portuguese separators — no BRL, no EUR string:
Money(currencyCode: 'EUR', displayLocale: 'pt_PT').format(1234.5); // '1.234,50 €'
Money(currencyCode: 'EUR', displayLocale: 'en_US').format(1234.5); // '€1,234.50'
Money(currencyCode: 'BRL', displayLocale: 'pt_BR').format(1234.5); // 'R\$ 1.234,50'
Store the currencyCode with the price/product (from your backend or pricing config), and pull displayLocale from the device or the user's preference. They come from different sources — treat them that way.
Tip: store money as integer minor units (cents) end-to-end and only convert to
doubleat the formatting boundary.NumberFormathandles the rounding for display; it can't undo float drift you introduced upstream.
Gotcha: the RTL dollar-sign scramble
In RTL layouts (Arabic, Hebrew), a bare $ or ISO code can jump to the wrong side — $100.50 renders as 100.50$ or gets reordered mid-string, because the Unicode bidi algorithm treats the symbol as a neutral character (flutter/flutter#20394).
The pragmatic fix is to render the formatted amount in an explicit LTR context so the number and symbol stay glued together:
Text(
Money(currencyCode: 'USD', displayLocale: 'ar').format(100.5),
textDirection: TextDirection.ltr, // keep '$100.50' intact inside RTL UI
)
Use this for symbols that are ambiguous in RTL; for currencies whose CLDR data already places the symbol correctly for the locale, let the formatter decide. Test with a real Arabic device locale, not just a widget preview.
Checklist to never ship the wrong currency
- Never infer currency from
locale. Passnameexplicitly. - Always pass
symbolwhen you want a glyph, not an ISO code. - Set
decimalDigitsfor zero-decimal (JPY/KRW) and three-decimal (KWD/BHD) currencies. - Keep the ISO code with the price data; keep the display locale with the user.
- Pin and test your
intlversion — currency behavior has regressed between releases.
Managing those locale strings and per-region price copy across dozens of ARB files is its own tax. FlutterLocalisation's ARB editor keeps your intl keys and translations in sync, and our pricing localization workflow helps you map regions to the right currency copy without hand-editing JSON.
Try FlutterLocalisation free and stop debugging currency strings by hand.