String.format Locale Bug: Why “1234,50” Throws NumberFormatException on Android

A String.format locale bug turns a price like 1234.50 into “1234,50” on a German phone. On an Egyptian Arabic phone it becomes “١٢٣٤٫٥٠”. Then toDouble() throws NumberFormatException. Two causes stack up. First, "%.2f".format(x) with no locale argument formats with the device’s default locale. That locale picks the decimal separator and even the digit characters. Second, every parser on the other end is locale-blind. toDouble() and the server’s JSON parser accept only ASCII digits and a dot. So the string is correct for a human and unreadable for a machine. The bug ships because developer machines, emulators and CI usually run in en-US, where both sides agree. The fix is to never send a localized string to a machine. Build wire values with BigDecimal.toPlainString() or Locale.ROOT. Format with the user’s locale only for text on screen.

A checkout body that works in the US and fails in Berlin and Cairo

The running example is a checkout screen. The cart holds its total as a Long of cents. That’s the right call for money. The repository turns that total into a request body for the payments API. A receipt cache later reads the amount back out of the same body. Here is the code, cut down to the two functions that matter.

import java.util.Locale

data class Cart(val totalCents: Long)

fun buildChargeBody(cart: Cart): String {
    val amount = "%.2f".format(cart.totalCents / 100.0)
    return """{"amount":"$amount","currency":"EUR"}"""
}

fun parseAmount(body: String): Double {
    val raw = Regex("\"amount\":\"([^\"]+)\"").find(body)!!.groupValues[1]
    return raw.toDouble()
}

fun main() {
    val cart = Cart(totalCents = 123450)
    for (tag in listOf("en-US", "de-DE", "ar-EG")) {
        Locale.setDefault(Locale.forLanguageTag(tag))
        val body = buildChargeBody(cart)
        val parsed = runCatching { parseAmount(body) }
        println("$tag  $body  -> ${parsed.getOrElse { it.toString() }}")
    }
}

Locale.setDefault stands in for the phone’s language setting. Compiled with kotlinc 2.4.20 and run on a JDK 27 JVM, it prints this:

en-US  {"amount":"1234.50","currency":"EUR"}  -> 1234.5
de-DE  {"amount":"1234,50","currency":"EUR"}  -> java.lang.NumberFormatException: For input string: "1234,50"
ar-EG  {"amount":"١٢٣٤٫٥٠","currency":"EUR"}  -> java.lang.NumberFormatException: For input string: "١٢٣٤٫٥٠"

The same cart produced three different request bodies. Only the US one survives the round trip. The payments server sees the same strings. Its parser rejects them just like toDouble() does, so the charge fails for those users. Locale data comes from CLDR and changes between releases. Android’s exact output can differ by OS version.

The String.format locale default changes the separator and the digits

Kotlin’s "%.2f".format(x) is a thin wrapper over Java’s formatter. The Kotlin stdlib docs for format say the overloads without a locale use the default locale. On Android, the String.format reference names it exactly. It uses Locale.getDefault(Locale.Category) with the FORMAT category.

The Formatter reference then describes a number localization step. It runs after the digits are computed. Each ASCII digit is shifted to the locale’s own zero digit. The decimal separator is replaced with the locale’s separator. With the , flag, the locale’s grouping separator goes in too. So de-DE swaps the dot for a comma. ar-EG swaps every digit for an Arabic-Indic one and uses the Arabic decimal separator.

The digit swap is easy to miss. It also hits %d, so integers aren’t safe either. Tailscale’s Android app hit exactly this in pull request #41, merged in March 2022. Its Java side built internal strings with %d. Under an Arabic-numeral locale, the Go side got digits it couldn’t parse. The fix switched those calls to Locale.ROOT.

Every parser on the other side ignores locale

The second cause is the asymmetry. Formatting is localized by default. Parsing is not. The Double reference defines the accepted input as a Java floating-point literal. That grammar has ASCII digits and a . as the decimal point. There’s no locale parameter at all. toDouble() on the JVM delegates to it, so "1234,50" is just invalid input.

The server is in the same position. A JSON number can only use ASCII digits and a dot, per RFC 8259. A backend that converts the amount string with its own decimal parser behaves like toDouble(). Nobody on the server side sees the phone’s locale.

Android’s own Locale reference describes this exact trap in a section titled “Be wary of the default locale”. It says the default locale is right for presenting data to users and wrong for machine-readable output. It adds that the mistake tends to pass on test devices, because so many developers run en_US. That explains why this bug reaches production. The JVM sets its default locale from the host at startup, per the same reference. Local unit tests run on that JVM. On an en-US laptop, every test passes.

The fix keeps wire values unlocalized and formats only for display

Split the two jobs. A wire value goes to another program, so it must be identical on every phone. A display value goes to a person, so it should follow their locale. The cart already stores cents, so BigDecimal can build the wire string with no formatter involved.

import java.math.BigDecimal
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale

data class Cart(val totalCents: Long)

// Wire value: same bytes on every device.
fun buildChargeBody(cart: Cart): String {
    val amount = BigDecimal.valueOf(cart.totalCents, 2).toPlainString()
    return """{"amount":"$amount","currency":"EUR"}"""
}

fun parseAmount(body: String): BigDecimal {
    val raw = Regex("\"amount\":\"([^\"]+)\"").find(body)!!.groupValues[1]
    return BigDecimal(raw)
}

// Display value: follows the user's locale on purpose.
fun displayTotal(cart: Cart, locale: Locale): String {
    val fmt = NumberFormat.getCurrencyInstance(locale)
    fmt.currency = Currency.getInstance("EUR")
    return fmt.format(BigDecimal.valueOf(cart.totalCents, 2))
}

fun main() {
    val cart = Cart(totalCents = 123450)
    for (tag in listOf("en-US", "de-DE", "ar-EG")) {
        val locale = Locale.forLanguageTag(tag)
        Locale.setDefault(locale)
        val body = buildChargeBody(cart)
        println("$tag  $body  -> ${parseAmount(body)}  shown as ${displayTotal(cart, locale)}")
    }
    println("ROOT format: " + "%.2f".format(Locale.ROOT, 1234.5))
}
en-US  {"amount":"1234.50","currency":"EUR"}  -> 1234.50  shown as €1,234.50
de-DE  {"amount":"1234.50","currency":"EUR"}  -> 1234.50  shown as 1.234,50 €
ar-EG  {"amount":"1234.50","currency":"EUR"}  -> 1234.50  shown as ‏١٬٢٣٤٫٥٠ €

The request body is now the same in all three locales. The receipt cache parses it back as an exact BigDecimal, with no Double rounding. The screen still shows each user their own format. If a formatter is unavoidable, pass the locale. The last line printed ROOT format: 1234.50. Android’s Locale reference suggests Locale.US for machine output. The DefaultLocale lint check docs suggest Locale.ROOT for internal strings. Both give ASCII digits and a dot. ROOT states the intent more clearly.

Then lock the fix in with a test. Set Locale.setDefault(Locale.forLanguageTag("ar-EG")) before the test and restore the old default after it. Assert that the body contains "1234.50". The broken version fails that test on any laptop. Turn on the DefaultLocale lint warning as well. It flags String.format calls with no explicit locale. Firefox for Android’s build tooling got this warning from AGP 9 lint, per Mozilla bug 2050917. Its build-metrics JSON changed with the build machine’s locale. The fix was Locale.ROOT.

This is the same class of bug as our Gson release-build post. The code is right in the environment you test and wrong in the one you ship.

What to say when the interviewer shows you the German crash report

This bug fits a bug-squash or debugging round. The prompt is often a crash report with NumberFormatException: For input string: "1234,50". It only happens for some users. Our Android debugging interview post covers how that round is scored. Here is the explanation to give.

  1. Read the input string. A comma where a dot should be points at locale, not at bad data from the server.
  2. Name the first cause. String.format with no locale uses the device’s default FORMAT locale. It localizes the decimal separator, the grouping separator and the digits themselves.
  3. Name the second cause. toDouble() and the server only accept ASCII digits and a dot. The formatter is localized and the parsers aren’t.
  4. Explain why tests missed it. The JVM default comes from the host. The team’s machines run en-US.
  5. Give the fix. Build wire values with BigDecimal.toPlainString() or an explicit Locale.ROOT. Use NumberFormat with the user’s locale only for display.
  6. Offer the guard. Add a unit test under an Arabic or German default locale. Keep the DefaultLocale lint check on.

Point 2 carries the most weight. Mentioning the Arabic digits shows you know that %d is affected too. A comma-to-dot patch can’t fix that.

Four fixes that pass the German test and break somewhere else

Each of these was run against the same JDK as above.

  • Replacing commas with dots fails twice. With grouping on, German output is "1.234,50". After the swap it becomes "1.234.50". Then toDouble() threw NumberFormatException: multiple points. The Arabic string has no comma at all, so it stayed unparseable.
  • Parsing with NumberFormat.getInstance() on the reading side is worse, because it fails silently. If the reader’s locale differs from the writer’s, the value shifts. A German NumberFormat parsed "12.50" as 1250. The dot was read as a thousands separator. That’s a hundredfold overcharge with no exception.
  • Assuming Kotlin’s stdlib is locale-safe mixes up two APIs. The uppercase() docs say it uses the invariant locale. Under tr-TR, "title".uppercase() still gave TITLE. In the same run, "%.2f".format(1.5) gave 1,50. Kotlin’s newer case functions default to the invariant locale. format doesn’t.
  • Calling Locale.setDefault(Locale.US) in Application.onCreate() trades one bug for another. Every user-facing date and number that relies on the default turns American. A default-locale currency formatter would show German users €1,234.50. It also hides the real problem from the next reader.

Suppressing the lint warning with @SuppressLint("DefaultLocale") belongs in the same group. It’s fine on a call that really is for display. On the charge body, it removes the one tool that would have caught this bug.

Leave a Comment