A PHP accounting export starts writing 1234,50 instead of 1234.50 after a library calls setlocale(); which formatting functions follow the locale, and how do you make the output locale-proof?
answer
- LC_NUMERIC sets the decimal point
- %f and %g follow it, %F does not
- float to string ignores locale since 8.0
- number_format uses explicit separators
- locale is per process, reset per request
basics
~20 ssprintf() and printf() with %f or %g use the LC_NUMERIC decimal point, so a setlocale() call turns 1234.50 into 1234,50. Use %F, or number_format($x, 2, '.', ''), for machine output; echoing a float ignores the locale since PHP 8.0.
solid answer
~40 s`setlocale(LC_ALL, 'de_DE.UTF-8')` or `LC_NUMERIC` changes the decimal point that locale-aware conversions use. In the printf family, `%f`, `%g` and `%G` are locale-aware, while `%F`, `%e`, `%E`, `%h` and `%H` always print a dot. Since PHP 8.0, casting or echoing a float is locale-independent, so `echo $amount` is safe; before 8.0 it was not. `number_format()` never consults the locale: it uses the separators you pass, defaulting to `.` and `,`. So for an export, write `sprintf('%.2F', $amount)` or `number_format($amount, 2, '.', '')`, and use explicit separators or intl's `NumberFormatter` only for human-facing text. The locale belongs to the process, not the request: PHP resets it at request end, but it persists for the rest of a request and in long-running CLI workers.
code
php · 13 lines<?php
declare(strict_types=1);
$amount = 1234.5;
// e.g. called by a localisation library; returns false if the locale is not installed
setlocale(LC_NUMERIC, 'de_DE.UTF-8');
echo sprintf('%.2f', $amount), "\n"; // 1234,50 (locale-aware)
echo sprintf('%.2F', $amount), "\n"; // 1234.50 (never localised)
echo $amount, "\n"; // 1234.5 (locale-independent since 8.0)
echo number_format($amount, 2, '.', ''), "\n"; // 1234.50 (machine format)
echo number_format($amount, 2, ',', '.'), "\n"; // 1.234,50 (explicit display format)go deeper
Recall that setlocale can change the decimal point and that number_format lets you choose the separators explicitly.
Explain which printf specifiers are locale-aware, what PHP 8.0 changed for float-to-string conversion, and how number_format builds its output.
Diagnose a comma appearing in an export by tracing setlocale calls, switch machine output to %F or explicit separators, and add a test under a comma-decimal locale.
Set a contract that machine-readable outputs are locale-independent, keep locale choices in per-request configuration or NumberFormatter, and forbid LC_ALL changes in shared bootstrap code.
## Where the comma comes from A **locale** is a set of C-library conventions for a language and region. `setlocale(int $category, ...)` changes them for the running process. The category that matters for numbers is **`LC_NUMERIC`**, which defines the decimal point (`,` in German, `.` in the default `C` locale). Calling `setlocale(LC_ALL, ...)` sets it too. The typical incident: a bootstrap file or a localisation library calls `setlocale(LC_ALL, 'de_DE.UTF-8')` so that month names come out in German. Every locale-aware number conversion in the same process now writes a comma, and an accounting export that feeds another system with `sprintf('%.2f', $amount)` starts producing `1234,50`. Nothing in the export code changed. ## Which conversions follow the locale | Conversion | Follows `LC_NUMERIC`? | Output for `1234.5` under a German locale | |---|---|---| | `sprintf('%.2f', $x)` | yes | `1234,50` | | `sprintf('%g', $x)` | yes | `1234,5` | | `sprintf('%.2F', $x)` | no | `1234.50` | | `sprintf('%.1e', $x)` | no | `1.2e+3` | | `(string) $x`, `echo $x`, interpolation | no, since PHP 8.0 | `1234.5` | | `number_format($x, 2)` | no, explicit separators | `1,234.50` | In the `printf` family the rule is per specifier: `f`, `g` and `G` read the locale's decimal point, while `F`, `e`, `E`, and the PHP 8.0 additions `h` and `H` always print `.`. That is why `F` exists at all: it is the non-locale-aware twin of `f`. Before PHP 8.0, converting a float to a string also followed the locale, so even `echo $amount` or `"$amount"` could write a comma. PHP 8.0 made that conversion **locale-independent**, which removed most of these bugs but left `%f` as it was. ## number_format() is not locale-aware `number_format(float $num, int $decimals = 0, ?string $decimal_separator = ".", ?string $thousands_separator = ","): string` builds its output with a fixed `.` internally and then substitutes the separators you pass. It never asks the locale: - `number_format(1234.5, 2)` gives `1,234.50` everywhere. - `number_format(1234.5, 2, ',', '.')` gives `1.234,50` everywhere. - `number_format(1234.5, 2, '.', '')` gives `1234.50`, a clean machine format. It rounds half away from zero to the requested decimals, has accepted three arguments since PHP 8.0, and since PHP 8.3 a negative `$decimals` rounds to tens, hundreds and so on. Its output is for display: feeding `'1,234.50'` back into arithmetic does not give 1234.5. ## How long a locale lives The locale is **per process, not per thread**. That has three consequences: 1. Under PHP-FPM, each worker process serves one request at a time. When a script has called `setlocale()`, PHP resets the locale to `C` at the end of the request, so the next request on that worker starts clean. 2. Within one request, the change affects **all** code that runs afterwards, including libraries you did not write. 3. A long-running CLI worker that handles many jobs in one process keeps the locale until something changes it back. On a threaded server API, concurrent scripts in other threads can see the change. ## Diagnosing the incident When a comma appears in an export that used to be correct, work through it in order: 1. Reproduce with the same entry point and data, and check which conversion writes the amount: `%f`, `%g`, or string interpolation. 2. Search the code and dependencies for `setlocale(` calls, especially with `LC_ALL` or `LC_NUMERIC`, and note when they run relative to the export. 3. Log `setlocale(LC_NUMERIC, '0')` just before the export; the string `'0'` queries the current setting without changing it (an integer `0` throws `TypeError` since PHP 8.5). 4. Confirm that the failing environment actually has the locale installed; on a server without it, `setlocale()` returns `false` and nothing changes, which explains why the bug appears on one host only. ## Making output locale-proof - **Machine-readable output** (CSV, fixed-width files, JSON built by hand, SQL literals): use `%F` in `sprintf()`, or `number_format($x, $decimals, '.', '')`. Never `%f`. - **Human-readable output**: pass explicit separators to `number_format()` from your own per-market configuration, or use the intl extension's `NumberFormatter`, which formats for a named locale without touching the process locale. - **Narrow `setlocale()` calls**: set only the category you need, such as `LC_TIME` or `LC_MESSAGES`, instead of `LC_ALL`, so `LC_NUMERIC` stays `C`. - **Test it**: run the export test once under a comma-decimal locale, so a regression to `%f` fails in CI rather than in the ledger. - **Money**: formatting cannot fix binary float rounding; keep amounts as integer minor units or decimal strings and format only at the edge.
- Why did plain echo $amount also print a comma on PHP 7 but not on PHP 8?Before PHP 8.0, converting a float to a string used the current `LC_NUMERIC` decimal point, so echo, concatenation and interpolation were all locale-dependent. PHP 8.0 made float-to-string conversion locale-independent. The printf family's `%f` kept its locale-aware behaviour, so that specifier is where the bug survives.
- Does a setlocale() call in one PHP-FPM request leak into the next request served by the same worker?No. When a script changed the locale, PHP restores the `C` locale at request shutdown. It does persist for the rest of that request, across every library that runs later, and for the whole life of a long-running CLI process. On a threaded server API it is shared by concurrent threads.
- Why is number_format() output a poor input for further calculations?It is a display string with separators, not a number. Casting `'1,234.50'` to float reads only the leading numeric part and yields `1.0`, silently. Keep the numeric value for arithmetic and call `number_format()` only when producing the final text.
saying these in an interview costs you the question
- sprintf's %f and %F are identical aliases.
- Since PHP 8.0 no formatting function looks at the locale any more.
- number_format picks its separators from the current locale.
- A setlocale call in one FPM request stays active for the next request on that worker.
- Casting the string '1,234.50' to float gives 1234.5.