{"id":796,"date":"2026-10-09T13:55:00","date_gmt":"2026-10-09T11:55:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=796"},"modified":"2026-10-08T18:32:56","modified_gmt":"2026-10-08T16:32:56","slug":"string-format-locale-numberformatexception","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/string-format-locale-numberformatexception\/","title":{"rendered":"String.format Locale Bug: Why &#8220;1234,50&#8221; Throws NumberFormatException on Android"},"content":{"rendered":"<p>A String.format locale bug turns a price like 1234.50 into &#8220;1234,50&#8221; on a German phone. On an Egyptian Arabic phone it becomes &#8220;\u0661\u0662\u0663\u0664\u066b\u0665\u0660&#8221;. Then <code>toDouble()<\/code> throws <code>NumberFormatException<\/code>. Two causes stack up. First, <code>\"%.2f\".format(x)<\/code> with no locale argument formats with the device&#8217;s default locale. That locale picks the decimal separator and even the digit characters. Second, every parser on the other end is locale-blind. <code>toDouble()<\/code> and the server&#8217;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 <code>BigDecimal.toPlainString()<\/code> or <code>Locale.ROOT<\/code>. Format with the user&#8217;s locale only for text on screen.<\/p>\n\n<h2>A checkout body that works in the US and fails in Berlin and Cairo<\/h2>\n\n<p>The running example is a checkout screen. The cart holds its total as a <code>Long<\/code> of cents. That&#8217;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.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">import java.util.Locale\n\ndata class Cart(val totalCents: Long)\n\nfun buildChargeBody(cart: Cart): String {\n    val amount = \"%.2f\".format(cart.totalCents \/ 100.0)\n    return \"\"\"{\"amount\":\"$amount\",\"currency\":\"EUR\"}\"\"\"\n}\n\nfun parseAmount(body: String): Double {\n    val raw = Regex(\"\\\"amount\\\":\\\"([^\\\"]+)\\\"\").find(body)!!.groupValues[1]\n    return raw.toDouble()\n}\n\nfun main() {\n    val cart = Cart(totalCents = 123450)\n    for (tag in listOf(\"en-US\", \"de-DE\", \"ar-EG\")) {\n        Locale.setDefault(Locale.forLanguageTag(tag))\n        val body = buildChargeBody(cart)\n        val parsed = runCatching { parseAmount(body) }\n        println(\"$tag  $body  -> ${parsed.getOrElse { it.toString() }}\")\n    }\n}<\/pre>\n\n\n<p><code>Locale.setDefault<\/code> stands in for the phone&#8217;s language setting. Compiled with <code>kotlinc<\/code> 2.4.20 and run on a JDK 27 JVM, it prints this:<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">en-US  {\"amount\":\"1234.50\",\"currency\":\"EUR\"}  -&gt; 1234.5\nde-DE  {\"amount\":\"1234,50\",\"currency\":\"EUR\"}  -&gt; java.lang.NumberFormatException: For input string: \"1234,50\"\nar-EG  {\"amount\":\"\u0661\u0662\u0663\u0664\u066b\u0665\u0660\",\"currency\":\"EUR\"}  -&gt; java.lang.NumberFormatException: For input string: \"\u0661\u0662\u0663\u0664\u066b\u0665\u0660\"<\/pre>\n\n\n<p>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 <code>toDouble()<\/code> does, so the charge fails for those users. Locale data comes from CLDR and changes between releases. Android&#8217;s exact output can differ by OS version.<\/p>\n\n<h2>The String.format locale default changes the separator and the digits<\/h2>\n\n<p>Kotlin&#8217;s <code>\"%.2f\".format(x)<\/code> is a thin wrapper over Java&#8217;s formatter. The <a href=\"https:\/\/kotlinlang.org\/api\/core\/kotlin-stdlib\/kotlin.text\/format.html\" target=\"_blank\" rel=\"noopener\">Kotlin stdlib docs for format<\/a> say the overloads without a locale use the default locale. On Android, the <a href=\"https:\/\/developer.android.com\/reference\/java\/lang\/String\" target=\"_blank\" rel=\"noopener\"><code>String.format<\/code> reference<\/a> names it exactly. It uses <code>Locale.getDefault(Locale.Category)<\/code> with the <code>FORMAT<\/code> category.<\/p>\n\n<p>The <a href=\"https:\/\/developer.android.com\/reference\/java\/util\/Formatter\" target=\"_blank\" rel=\"noopener\"><code>Formatter<\/code> reference<\/a> then describes a number localization step. It runs after the digits are computed. Each ASCII digit is shifted to the locale&#8217;s own zero digit. The decimal separator is replaced with the locale&#8217;s separator. With the <code>,<\/code> flag, the locale&#8217;s grouping separator goes in too. So <code>de-DE<\/code> swaps the dot for a comma. <code>ar-EG<\/code> swaps every digit for an Arabic-Indic one and uses the Arabic decimal separator.<\/p>\n\n<p>The digit swap is easy to miss. It also hits <code>%d<\/code>, so integers aren&#8217;t safe either. Tailscale&#8217;s Android app hit exactly this in <a href=\"https:\/\/github.com\/tailscale\/tailscale-android\/pull\/41\" target=\"_blank\" rel=\"noopener\">pull request #41, merged in March 2022<\/a>. Its Java side built internal strings with <code>%d<\/code>. Under an Arabic-numeral locale, the Go side got digits it couldn&#8217;t parse. The fix switched those calls to <code>Locale.ROOT<\/code>.<\/p>\n\n<h2>Every parser on the other side ignores locale<\/h2>\n\n<p>The second cause is the asymmetry. Formatting is localized by default. Parsing is not. The <a href=\"https:\/\/developer.android.com\/reference\/java\/lang\/Double\" target=\"_blank\" rel=\"noopener\"><code>Double<\/code> reference<\/a> defines the accepted input as a Java floating-point literal. That grammar has ASCII digits and a <code>.<\/code> as the decimal point. There&#8217;s no locale parameter at all. <code>toDouble()<\/code> on the JVM delegates to it, so <code>\"1234,50\"<\/code> is just invalid input.<\/p>\n\n<p>The server is in the same position. A JSON number can only use ASCII digits and a dot, per <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc8259#section-6\" target=\"_blank\" rel=\"noopener\">RFC 8259<\/a>. A backend that converts the <code>amount<\/code> string with its own decimal parser behaves like <code>toDouble()<\/code>. Nobody on the server side sees the phone&#8217;s locale.<\/p>\n\n<p>Android&#8217;s own <a href=\"https:\/\/developer.android.com\/reference\/java\/util\/Locale\" target=\"_blank\" rel=\"noopener\"><code>Locale<\/code> reference<\/a> describes this exact trap in a section titled &#8220;Be wary of the default locale&#8221;. 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.<\/p>\n\n<h2>The fix keeps wire values unlocalized and formats only for display<\/h2>\n\n<p>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 <code>BigDecimal<\/code> can build the wire string with no formatter involved.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">import java.math.BigDecimal\nimport java.text.NumberFormat\nimport java.util.Currency\nimport java.util.Locale\n\ndata class Cart(val totalCents: Long)\n\n\/\/ Wire value: same bytes on every device.\nfun buildChargeBody(cart: Cart): String {\n    val amount = BigDecimal.valueOf(cart.totalCents, 2).toPlainString()\n    return \"\"\"{\"amount\":\"$amount\",\"currency\":\"EUR\"}\"\"\"\n}\n\nfun parseAmount(body: String): BigDecimal {\n    val raw = Regex(\"\\\"amount\\\":\\\"([^\\\"]+)\\\"\").find(body)!!.groupValues[1]\n    return BigDecimal(raw)\n}\n\n\/\/ Display value: follows the user's locale on purpose.\nfun displayTotal(cart: Cart, locale: Locale): String {\n    val fmt = NumberFormat.getCurrencyInstance(locale)\n    fmt.currency = Currency.getInstance(\"EUR\")\n    return fmt.format(BigDecimal.valueOf(cart.totalCents, 2))\n}\n\nfun main() {\n    val cart = Cart(totalCents = 123450)\n    for (tag in listOf(\"en-US\", \"de-DE\", \"ar-EG\")) {\n        val locale = Locale.forLanguageTag(tag)\n        Locale.setDefault(locale)\n        val body = buildChargeBody(cart)\n        println(\"$tag  $body  -> ${parseAmount(body)}  shown as ${displayTotal(cart, locale)}\")\n    }\n    println(\"ROOT format: \" + \"%.2f\".format(Locale.ROOT, 1234.5))\n}<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">en-US  {\"amount\":\"1234.50\",\"currency\":\"EUR\"}  -&gt; 1234.50  shown as \u20ac1,234.50\nde-DE  {\"amount\":\"1234.50\",\"currency\":\"EUR\"}  -&gt; 1234.50  shown as 1.234,50 \u20ac\nar-EG  {\"amount\":\"1234.50\",\"currency\":\"EUR\"}  -&gt; 1234.50  shown as \u200f\u0661\u066c\u0662\u0663\u0664\u066b\u0665\u0660 \u20ac<\/pre>\n\n\n<p>The request body is now the same in all three locales. The receipt cache parses it back as an exact <code>BigDecimal<\/code>, with no <code>Double<\/code> rounding. The screen still shows each user their own format. If a formatter is unavoidable, pass the locale. The last line printed <code>ROOT format: 1234.50<\/code>. Android&#8217;s <code>Locale<\/code> reference suggests <code>Locale.US<\/code> for machine output. The <a href=\"https:\/\/googlesamples.github.io\/android-custom-lint-rules\/checks\/DefaultLocale.md.html\" target=\"_blank\" rel=\"noopener\">DefaultLocale lint check docs<\/a> suggest <code>Locale.ROOT<\/code> for internal strings. Both give ASCII digits and a dot. <code>ROOT<\/code> states the intent more clearly.<\/p>\n\n<p>Then lock the fix in with a test. Set <code>Locale.setDefault(Locale.forLanguageTag(\"ar-EG\"))<\/code> before the test and restore the old default after it. Assert that the body contains <code>\"1234.50\"<\/code>. The broken version fails that test on any laptop. Turn on the <code>DefaultLocale<\/code> lint warning as well. It flags <code>String.format<\/code> calls with no explicit locale. Firefox for Android&#8217;s build tooling got this warning from AGP 9 lint, per <a href=\"https:\/\/bugzilla.mozilla.org\/show_bug.cgi?id=2050917\" target=\"_blank\" rel=\"noopener\">Mozilla bug 2050917<\/a>. Its build-metrics JSON changed with the build machine&#8217;s locale. The fix was <code>Locale.ROOT<\/code>.<\/p>\n\n<p>This is the same class of bug as our <a href=\"https:\/\/grindloop.ai\/blog\/gson-null-fields-release-build\/\">Gson release-build post<\/a>. The code is right in the environment you test and wrong in the one you ship.<\/p>\n\n<h2>What to say when the interviewer shows you the German crash report<\/h2>\n\n<p>This bug fits a bug-squash or debugging round. The prompt is often a crash report with <code>NumberFormatException: For input string: \"1234,50\"<\/code>. It only happens for some users. Our <a href=\"https:\/\/grindloop.ai\/blog\/android-debugging-interview-second-bug\/\">Android debugging interview post<\/a> covers how that round is scored. Here is the explanation to give.<\/p>\n\n<ol>\n<li>Read the input string. A comma where a dot should be points at locale, not at bad data from the server.<\/li>\n<li>Name the first cause. <code>String.format<\/code> with no locale uses the device&#8217;s default <code>FORMAT<\/code> locale. It localizes the decimal separator, the grouping separator and the digits themselves.<\/li>\n<li>Name the second cause. <code>toDouble()<\/code> and the server only accept ASCII digits and a dot. The formatter is localized and the parsers aren&#8217;t.<\/li>\n<li>Explain why tests missed it. The JVM default comes from the host. The team&#8217;s machines run en-US.<\/li>\n<li>Give the fix. Build wire values with <code>BigDecimal.toPlainString()<\/code> or an explicit <code>Locale.ROOT<\/code>. Use <code>NumberFormat<\/code> with the user&#8217;s locale only for display.<\/li>\n<li>Offer the guard. Add a unit test under an Arabic or German default locale. Keep the <code>DefaultLocale<\/code> lint check on.<\/li>\n<\/ol>\n\n<p>Point 2 carries the most weight. Mentioning the Arabic digits shows you know that <code>%d<\/code> is affected too. A comma-to-dot patch can&#8217;t fix that.<\/p>\n\n<h2>Four fixes that pass the German test and break somewhere else<\/h2>\n\n<p>Each of these was run against the same JDK as above.<\/p>\n\n<ul>\n<li>Replacing commas with dots fails twice. With grouping on, German output is <code>\"1.234,50\"<\/code>. After the swap it becomes <code>\"1.234.50\"<\/code>. Then <code>toDouble()<\/code> threw <code>NumberFormatException: multiple points<\/code>. The Arabic string has no comma at all, so it stayed unparseable.<\/li>\n<li>Parsing with <code>NumberFormat.getInstance()<\/code> on the reading side is worse, because it fails silently. If the reader&#8217;s locale differs from the writer&#8217;s, the value shifts. A German <code>NumberFormat<\/code> parsed <code>\"12.50\"<\/code> as <code>1250<\/code>. The dot was read as a thousands separator. That&#8217;s a hundredfold overcharge with no exception.<\/li>\n<li>Assuming Kotlin&#8217;s stdlib is locale-safe mixes up two APIs. The <a href=\"https:\/\/kotlinlang.org\/api\/core\/kotlin-stdlib\/kotlin.text\/uppercase.html\" target=\"_blank\" rel=\"noopener\"><code>uppercase()<\/code> docs<\/a> say it uses the invariant locale. Under <code>tr-TR<\/code>, <code>\"title\".uppercase()<\/code> still gave <code>TITLE<\/code>. In the same run, <code>\"%.2f\".format(1.5)<\/code> gave <code>1,50<\/code>. Kotlin&#8217;s newer case functions default to the invariant locale. <code>format<\/code> doesn&#8217;t.<\/li>\n<li>Calling <code>Locale.setDefault(Locale.US)<\/code> in <code>Application.onCreate()<\/code> 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 <code>\u20ac1,234.50<\/code>. It also hides the real problem from the next reader.<\/li>\n<\/ul>\n\n<p>Suppressing the lint warning with <code>@SuppressLint(\"DefaultLocale\")<\/code> belongs in the same group. It&#8217;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.<\/p>","protected":false},"excerpt":{"rendered":"<p>A String.format locale bug turns 1234.50 into 1234,50 or Arabic digits, so toDouble() throws NumberFormatException. Runnable repro, fix and interview answer.<\/p>\n","protected":false},"author":2,"featured_media":798,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"String.format Locale Bug: Why \"1234,50\" Throws NumberFormatException on Android","rank_math_description":"A String.format locale bug turns 1234.50 into 1234,50 or Arabic digits, so toDouble() throws NumberFormatException. Runnable repro, fix and interview answer.","rank_math_focus_keyword":"string.format locale","footnotes":""},"categories":[9,7],"tags":[15,87,16,12,104,45],"class_list":["post-796","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-java","tag-android","tag-debugging-interview","tag-interview-prep","tag-kotlin","tag-localization","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/796","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=796"}],"version-history":[{"count":1,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/796\/revisions"}],"predecessor-version":[{"id":797,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/796\/revisions\/797"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/798"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=796"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=796"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=796"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}