SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: toLowerCase() and format() read a locale nobody passed them

· by Serguey Asael Shinder / Serguey Shinder

These two lines have no locale in them, and both of them depend on one:

String key = header.toLowerCase();
String body = String.format("%.2f", amount);

The javadoc says so in both cases, and it is worth having the exact wording, because the two methods do not read the same default.

String.toLowerCase():

"Converts all of the characters in this String to lower case using the rules of the default locale. This method is equivalent to toLowerCase(Locale.getDefault())."

and its API note:

"This method is locale sensitive, and may produce unexpected results if used for strings that are intended to be interpreted locale independently. Examples are programming language identifiers, protocol keys, and HTML tags. For instance, "TITLE".toLowerCase() in a Turkish locale returns "tıtle", where 'ı' is the LATIN SMALL LETTER DOTLESS I character. To obtain correct results for locale insensitive strings, use toLowerCase(Locale.ROOT)."

String.format(String, Object...):

"The locale always used is the one returned by Locale.getDefault(Locale.Category) with FORMAT category specified."

So toLowerCase() goes to Locale.getDefault() and format goes to Locale.getDefault(Locale.Category.FORMAT). Those are two values. Locale.setDefault(Locale) sets them together — "By setting the default locale with this method, all of the default locales for each Category are also set to the specified default locale" — but Locale.setDefault(Locale.Category, Locale) sets one category alone. A process that has called the two-argument form once can lowercase under one locale and format numbers under another, for the rest of its life.

Serguey Asael Shinder: toLowerCase() and format() read a locale nobody passed them
toLowerCase() and format() read a locale nobody passed them — Serguey Asael Shinder

Where the initial value comes from

"The Java Virtual Machine sets the default locale during startup based on the host environment."

That sentence is the actual problem. It means the behaviour of both lines above is a property of the machine the JVM started on, not of the code, the artefact, or the test run. The same jar in a different container image is a different program.

Formatter replaces digits, not just separators

The consequence people expect is a comma instead of a full stop. The documented algorithm is broader than that:

"Each digit character d in the string is replaced by a locale-specific digit computed relative to the current locale's zero digit z; that is d - '0' + z. If a decimal separator is present, a locale-specific decimal separator is substituted."

A locale whose zero digit is not '0' therefore produces a number in which none of the digits are ASCII. That output will not survive Integer.parseInt, a regex of \d+ compiled without UNICODE_CHARACTER_CLASS, a numeric column in a database, or a JSON consumer. And it is correct behaviour: format was asked for a human-readable number in the ambient locale, and it produced one.

The rule that removes the whole class

Machine-facing strings: pass Locale.ROOT. Header names, protocol keys, enum names, file extensions, generated identifiers, anything compared to a constant in the source code, anything written into a wire format:

String key = header.toLowerCase(Locale.ROOT);
String body = String.format(Locale.ROOT, "%.2f", amount);

Human-facing strings: pass the user's locale, explicitly. Not the default — the default is the server's, and the server is not the reader. The locale belongs in the same argument list as the value, carried from the request:

String shown = String.format(request.locale(), "%.2f", amount);

Then the no-arg overload becomes a smell you can grep for. toLowerCase(), toUpperCase(), String.format( without a leading locale, new SimpleDateFormat(String), NumberFormat.getInstance() — every one of them is a method that reads ambient state. None of them are deprecated, and none of them are wrong; they are shorthand for a decision, and the shorthand hides which decision it made.

Do not fix it by pinning the JVM default at startup. It looks like the cheap fix, and setDefault's own javadoc warns against it: "Since changing the default locale may affect many different areas of functionality, this method should only be used if the caller is prepared to reinitialize locale-sensitive code running within the same Java Virtual Machine." Pinning the default makes the machine-facing strings correct by making every human-facing string wrong, and it does so process-wide, including for libraries you did not write.

References: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html · https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Locale.html · https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Formatter.html

⚠️ There is no JDK on the machine this was written on, so none of the snippets were compiled or run. Every behavioural claim above is quoted from the javadoc pages linked here.