Formatting numbers, dates, and currencies feels like a solved problem — until your app displays $1.234,56 to a German user, renders a negative balance without the minus sign in an Arabic locale, or ships a date string that means October 3rd in San Francisco and March 10th in Berlin. These are production bugs, and they happen because most teams treat formatting as an afterthought to the "real" localization work. This post is the guide that should have been in your onboarding docs.
This is part of a series on platform-specific i18n runtime behavior for mobile apps. If you're dealing with RTL layout issues in parallel, the RTL localization pre-release checklist is a good companion read.
Part 1: iOS
iOS gives you powerful formatting tools out of the box. The problem isn't that they're hard to use — it's that they're easy to misuse, and the wrong patterns are everywhere in tutorials and Stack Overflow answers.
NumberFormatter and DateFormatter are both locale-aware by default when you set their locale property correctly. The core principle: never construct a format string yourself. Let the formatter do it.
// Currency formatting — the right way
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale.current
// For a specific currency, set the currency code explicitly
formatter.currencyCode = "EUR"
let result = formatter.string(from: 1234.5) // "€1,234.50" in en-US, "1.234,50 €" in de-DE
For JPY, which has zero decimal places, NumberFormatter handles this automatically when the currencyCode is set correctly:
let jpyFormatter = NumberFormatter()
jpyFormatter.numberStyle = .currency
jpyFormatter.currencyCode = "JPY"
jpyFormatter.locale = Locale(identifier: "ja_JP")
jpyFormatter.string(from: 1500) // "¥1,500"
// In an en-US locale with JPY:
jpyFormatter.locale = Locale(identifier: "en_US")
jpyFormatter.string(from: 1500) // "¥1,500" — still zero decimal places, correct
The formatter knows JPY doesn't use fractional units. You don't have to set maximumFractionDigits = 0 yourself — in fact, hardcoding that is how you create bugs for currencies that do use decimals.
// Wrong — don't do this
let formatted = String(format: "%.2f", price)
// Also wrong — locale-hardcoded
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yyyy" // This is only correct for en-US
Hardcoded format strings are the root cause of most formatting bugs in shipped iOS apps. "MM/dd/yyyy" is unambiguous to exactly one locale cluster. For everyone else, it's either confusing or actively wrong. Use .dateStyle and .timeStyle instead:
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
dateFormatter.locale = Locale.current
dateFormatter.string(from: Date()) // "Jan 15, 2025" in en-US, "15 jan. 2025" in fr-FR
Locale.current vs. Locale(identifier:)
This is a subtle but important distinction:
Locale.current reflects the user's actual device locale settings, including any regional format overrides they've set. Use this for displaying values to users.
Locale(identifier: "en_US_POSIX") is a fixed, invariant locale. Use this for parsing or serializing data that crosses a network boundary — API dates, stored values, anything that needs to be locale-independent.
// Parsing an API date — use POSIX
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
// Displaying to user — use current
let display = DateFormatter()
display.locale = Locale.current
display.dateStyle = .long
Conflating these two is how you get crashes or silent data corruption when a user in a non-Gregorian calendar locale (Persian, Hebrew, Buddhist) triggers a date parse that wasn't expecting their calendar system.
.stringsdict for Locale-Aware Number Ranges
.stringsdict isn't just for pluralization — it's the right place to handle locale-aware string construction that depends on number values. If you're building localization at scale, the iOS pluralization guide covers this in depth.
Part 2: Android
Android's formatting story is more fragmented than iOS. You're dealing with the Java standard library, Android's own APIs, and — if you're supporting API levels below 26 — the ThreeTenABP backport for java.time.
// Currency formatting
val formatter = NumberFormat.getCurrencyInstance(Locale.getDefault())
formatter.currency = Currency.getInstance("EUR")
val result = formatter.format(1234.5) // Locale-appropriate output
// For JPY
val jpyFormatter = NumberFormat.getCurrencyInstance(Locale.JAPAN)
jpyFormatter.currency = Currency.getInstance("JPY")
jpyFormatter.format(1500) // "¥1,500"
For dates, prefer java.time (API 26+) or ThreeTenABP for lower targets:
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
val date = LocalDate.now()
val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
date.format(formatter) // Locale-aware, no hardcoded pattern
Android supports ICU message format in strings.xml starting with API 24. This is the correct way to embed number formatting inside translatable strings:
<!-- strings.xml -->
<string name="items_in_cart">
{count, plural,
=0 {No items in cart}
one {# item in cart}
other {# items in cart}
}
</string>
For currency values embedded in strings, use argument formatting:
<string name="order_total">Order total: {amount, number, currency}</string>
This matters because in Arabic, the ICU {amount, number} token will render using Arabic-Indic numerals (٣٤٥) when the locale is ar, which is what users in those markets expect. A raw %s substitution with a pre-formatted string from Java gives you no such guarantee.
This is where teams get into trouble:
// Dangerous — String.format doesn't understand locale-aware argument reordering
val broken = String.format("You have %d messages", count)
// Better — MessageFormat respects locale argument order
val message = MessageFormat("{0} messages received", arrayOf(count))
.format(arrayOf(count), StringBuffer(), null)
.toString()
// Best — use Android's getString with ICU format in resources
val best = getString(R.string.message_count, count)
In Arabic, sentence structure can require the number to appear at a different position than in English. String.format with positional arguments (%1$d) handles reordering syntactically, but MessageFormat with ICU handles it semantically. The Android plurals guide has more detail on how pluralization interacts with these patterns.
// Compact notation: 1000 → "1K", 1000000 → "1M"
// API 30+ only natively; below that, use ICU4J or a compat library
val compactFormatter = NumberFormat.getCompactNumberInstance(
Locale.getDefault(),
NumberFormat.Style.SHORT
)
compactFormatter.format(1_500_000) // "1.5M" in en-US, "1,5 Mio." in de-DE
// Don't assume "K" and "M" are universal — they're not
Locale.getDefault() Pitfalls
Locale.getDefault() returns the device locale, but it can return a locale that differs from the display language the user has selected, particularly on Android where users can set a separate region. Always test with Locale.getDefault(Locale.Category.FORMAT) when you specifically care about number/date formatting conventions:
// For formatting — use FORMAT category
val formatLocale = Locale.getDefault(Locale.Category.FORMAT)
// For display language (UI strings) — use DISPLAY category
val displayLocale = Locale.getDefault(Locale.Category.DISPLAY)
These can differ. A user might want English UI but German number formatting (period as thousands separator, comma as decimal). Respect that.
Part 3: React Native
React Native's formatting situation is the most variable of the three platforms, and it's the one that most frequently produces inconsistent results between iOS and Android builds of the same codebase.
React Native itself doesn't ship locale-aware formatters. You're working with JavaScript, which means Intl.NumberFormat and Intl.DateTimeFormat — if the runtime supports them.
// This works in a browser and in some RN configurations
const formatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
formatter.format(1234.5); // "1.234,50 €"
The problem is "if the runtime supports them."
Hermes and ICU Data
Hermes, Meta's JavaScript engine used in React Native by default since RN 0.70, ships with partial ICU data by default to keep binary size down. The practical consequence: Intl.NumberFormat is available, but locale support is incomplete. You may get correct output for en-US and fall back to en-US behavior for locales that aren't bundled.
To get full ICU support with Hermes:
Android (android/app/build.gradle):
android {
defaultConfig {
// Enable full ICU support
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
}
dependencies {
implementation 'com.facebook.react:react-android'
// For full ICU:
implementation 'org.chromium.net:cronet-embedded:119.6045.31'
}
The more practical approach is to use the react-native-localize library in combination with a polyfill:
npm install react-native-localize @formatjs/intl-numberformat
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/de';
import '@formatjs/intl-numberformat/locale-data/ar';
import '@formatjs/intl-numberformat/locale-data/ja';
This gives you correct Intl behavior regardless of what the Hermes runtime bundles.
Library Tradeoffs
| Library |
Format Coverage |
Bundle Size Impact |
Recommendation |
Intl.NumberFormat (native) |
Partial with Hermes |
None |
Use with polyfill |
react-intl |
Full, ICU-based |
Medium |
Good for apps already using i18n messages |
date-fns + locale plugins |
Date only |
Per-locale import |
Good for date-only needs |
dayjs + locale plugins |
Date/time, lightweight |
Very small |
Good default for date display |
import { getLocales } from 'react-native-localize';
function formatCurrency(amount, currencyCode) {
const locales = getLocales();
const localeTag = locales[0]?.languageTag ?? 'en-US';
try {
return new Intl.NumberFormat(localeTag, {
style: 'currency',
currency: currencyCode,
// JPY and similar zero-decimal currencies are handled automatically
}).format(amount);
} catch (e) {
// Fallback for unsupported locales
return `${currencyCode} ${amount.toFixed(2)}`;
}
}
// Usage
formatCurrency(1500, 'JPY'); // "¥1,500" in ja-JP
formatCurrency(1234.5, 'EUR'); // "1.234,50 €" in de-DE
The try/catch isn't paranoia — it's real. Unsupported locale tags will throw in some Hermes configurations.
For a deeper look at how platform-specific localization differences surface in React Native vs. native apps, this platform-specific localization gotchas post covers the broader picture.
Here's a scenario that's more common than it should be: your iOS app shows 1.234,50 € for a German user (correct), and your Android app shows €1,234.50 for the same user (wrong, or at least inconsistent). Both apps are technically "working." Your users notice immediately because they're switching between devices.
Why This Happens
- iOS uses
Locale.current implicitly in some contexts and falls back correctly
- Android's
NumberFormat.getCurrencyInstance() may be initialized with a stale or incorrect locale depending on when in the Activity lifecycle it's called
- React Native may be using a different locale detection mechanism than either native layer
The most robust solution is to treat formatting as a shared concern, not a per-platform implementation detail. Strategies:
-
Define a formatting contract in your design system. Document which number format, date format, and currency display rules your app uses per locale, and test against that contract on all platforms.
-
Use a translation platform that validates format tokens. When you have translatable strings containing {amount, number, currency} ICU tokens, a managed platform preserves those tokens through the translation process. Raw AI tools often don't — more on this below.
-
Audit locale detection at startup. Log the resolved locale on both platforms and confirm they agree before you assume formatting is consistent.
This is a real and underappreciated problem. Tools like raw ChatGPT, when given ICU-formatted strings for translation, will often silently rewrite or remove format tokens:
Input: "Balance: {amount, number, currency}"
Output: "Solde: {montant}" ← token structure broken
The translation looks plausible. The app crashes or displays garbage at runtime. This is documented behavior — the AI translation problems solutions page covers the failure modes, and the pattern of AI tools corrupting mobile resource files is a recurring theme in why ChatGPT breaks strings.xml and Localizable.strings. The short version: any translation pipeline that touches ICU format strings needs format token validation, not just spell-checking.
Most localization testing focuses on translated text. Formatting bugs are different — they're often not visible in a spot-check because the string itself looks fine. "Your balance is {amount}" translates correctly, but if {amount} renders as 1234.5 instead of 1.234,50 for a German user, the test passes and the bug ships.
Build snapshot tests that cover formatted output, not just raw strings:
// Android — JUnit test with locale injection
@Test
fun testCurrencyFormattingInGerman() {
val locale = Locale("de", "DE")
val formatter = NumberFormat.getCurrencyInstance(locale)
formatter.currency = Currency.getInstance("EUR")
assertEquals("1.234,50 €", formatter.format(1234.5))
assertEquals("-1.234,50 €", formatter.format(-1234.5)) // Test negatives
assertEquals("0,00 €", formatter.format(0)) // Test zero
}
// iOS — XCTest
func testCurrencyFormattingInGerman() {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: "de_DE")
formatter.currencyCode = "EUR"
XCTAssertEqual(formatter.string(from: 1234.5), "1.234,50 €")
XCTAssertEqual(formatter.string(from: -1234.5), "-1.234,50 €")
XCTAssertEqual(formatter.string(from: 0), "0,00 €")
}
Edge Cases That Always Bite
Cover these in every locale you ship:
- Arabic-Indic numerals: In
ar locale, 1234 may render as ١٢٣٤. If your layout expects Latin numerals, it will break. Test this explicitly.
- Negative values: Negative number display varies — some locales use parentheses
(1,234.50) instead of a minus sign.
- Zero: Currency zero (
$0.00 vs. $0) and how it displays in RTL locales.
- Very large numbers: Compact formatting (
1K, 1M) isn't universal, and the compact notation itself is locale-specific.
- Swiss German (de-CH): Uses
CHF not €, and uses a period as the thousands separator — different from de-DE. A common oversight when teams add "German" support and assume de-DE covers Switzerland.
Pseudo-Localization Doesn't Catch This
Pseudo-localization (replacing characters with accented equivalents, expanding string length) is excellent for catching layout bugs and hardcoded strings. It catches almost nothing about number formatting, because pseudo-localization operates on string content, not on locale runtime behavior.
For formatting, you need actual locale injection in tests — setting the device locale programmatically (or using Locale injection in unit tests) and asserting on formatted output. The localization testing guide has a broader framework for structuring this kind of test suite.
Before you enable a new locale in production, run through this list:
-
Currency decimal places: Confirm zero-decimal currencies (JPY, KRW, VND) display without fractional units, and that you're not hardcoding maximumFractionDigits.
-
Currency symbol position: Test that the currency symbol (or code) appears in the correct position for the target locale — not all locales put it on the left.
-
Thousands and decimal separators: Verify period vs. comma conventions are correct. German uses . for thousands and , for decimals. The inverse of en-US.
-
Negative number display: Confirm negative values display correctly and legibly in the target locale, including in RTL contexts.
-
Date format ambiguity: No hardcoded MM/dd/yyyy or equivalent. All date display goes through a locale-aware formatter with appropriate style.
-
Arabic-Indic numeral rendering: If you're shipping Arabic, explicitly test that numeral rendering matches user expectations (Eastern Arabic vs. Western Arabic digits — this varies by country).
-
ICU format token integrity: If you're using ICU message format strings, verify that the translation pipeline preserved token syntax. Don't assume — validate with a parser.
-
Locale detection agreement across platforms: If you ship iOS and Android, verify both apps resolve to the same locale for a given device configuration and produce consistent formatted output.
Formatting is one of those localization categories where the bugs are invisible in development and obvious to users in production. The patterns here — letting platform formatters do their job, testing explicitly across locales, and keeping format logic out of translation strings — are not difficult to apply. They just require treating formatting as a first-class concern rather than something you'll clean up in the next sprint.
The next post in this series covers runtime locale switching: what happens when a user changes their app language without restarting, and why both iOS and Android handle this in ways that will surprise you.