Skip to content
Allin

Formatting Numbers for Six Languages: Separators, Currency and the Parse Back

Published 10/3/2025 · 13 min read · Text & language tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 5 sources

View profile
In short

The same quantity is written 1,234,567.891 in English, 1.234.567,891 in Spanish, German and Italian, and 1 234 567,891 in French — and the spaces in the French version are not spaces you can type. Running Intl.NumberFormat on Node 26 with ICU 78 gives U+202F NARROW NO-BREAK SPACE as the French group separator and U+00A0 NO-BREAK SPACE as the European Portuguese one, both invisible and both fatal to a parser that expects a plain space or a comma. Three of the six locales also refuse to group four-digit numbers: Spanish, European Portuguese and Italian write 1234 without a separator but 10.000 with one, because CLDR sets their minimum grouping digits to two. Currency layout splits the same way: English puts the symbol before the digits with nothing between, while the other five put it after the number with a no-break space. None of this can be undone with Number.parseFloat, which returns 1 for the English string and a plausible-looking 1.234 for the German one. The only safe parse asks Intl.NumberFormat.formatToParts which characters this locale actually uses, then removes the group separator before converting the decimal separator — in that order, because doing it the other way multiplies the value by a thousand.

1,234.56 and 1.234,56 are the same number, and confusing them changes the value a reader parses. We ran Intl.NumberFormat for all six site locales and printed every separator — including the invisible one French uses — then measured why parseFloat cannot undo any of it.

The same number, six spellings

We took one value, 1234567.891, and formatted it with Intl.NumberFormat in the six locales this site publishes in. English gives 1,234,567.891. Spanish, German and Italian all give 1.234.567,891 — comma and full stop have swapped roles, so a reader who applies English habits reads a number a thousand times too small and does not notice. French gives 1 234 567,891 and European Portuguese gives 1 234 567,891 as well, but with a different invisible character doing the spacing.

This is not a presentation detail. A spreadsheet column pasted from one convention into a system expecting the other silently changes every value in it, and the failure is invisible because both spellings are legal numbers. The rule worth internalising is that a formatted number is a piece of text about a value, in a particular language, and the language has to travel with it.

Two more conventions are worth knowing because they turn up in European data. Swiss German uses an apostrophe: de-CH formats the same value as 1'234'567.891, with a full stop for the decimal. And a bare language tag is not the same as a language-and-region tag: asking Intl for "pt" produces Brazilian conventions, 1.234.567,891, while "pt-PT" produces the space-separated European form. If your Portuguese audience is European, the bare tag is quietly wrong.

The separator you cannot see

The French group separator in current CLDR data is not the space bar. Asking Intl.NumberFormat for the parts of a French number returns U+202F NARROW NO-BREAK SPACE. European Portuguese uses U+00A0 NO-BREAK SPACE, a different character with a different width. Both look exactly like a space in every editor, every terminal and every browser, and neither is one.

The consequences are entirely practical. A validation rule written as /^[\d ,]+$/ rejects a correctly formatted French number, because the character in it is not the space in the character class. A search-and-replace that strips spaces leaves the separator untouched. A CSV export produces cells a spreadsheet reads as text rather than numbers. And copying the number out of a page and pasting it into a calculator gives an error nobody can explain, because the offending character is invisible on both sides of the paste.

The right move is never to guess the separator. Intl.NumberFormat(locale).formatToParts(12345.6) returns a small array in which one entry has type "group" and another has type "decimal", and their values are the exact characters that locale uses today. Read them at runtime and your code keeps working when CLDR changes, which it does — the French separator was a plain no-break space in older data before the narrow one replaced it.

Three locales do not group four-digit numbers

Format 1234 in the six locales and the results do not line up. English gives 1,234, French gives 1 234 and German gives 1.234 — but Spanish, European Portuguese and Italian all give 1234, with no separator at all. Go up to 10000 and every one of them groups: 10,000, 10 000, 10.000 and 10.000 respectively. The switch happens between four and five digits.

The rule behind it is a CLDR setting called minimum grouping digits, which says how many digits must sit to the left of the first separator before grouping is worth doing. Spanish, European Portuguese and Italian set it to two; English, French and German set it to one. It exists because a four-digit number in those languages is often a year or a reference and reads better unbroken. If you need the grouping anyway — a table of amounts where the columns must align — pass useGrouping: "always" and Spanish returns 1.234. The reverse option, useGrouping: "min2", makes English behave like Spanish and print 1234.

Currency and percent: where the symbol goes

For an amount of 1234.50, English formats $1,234.50 — symbol first, no space, grouping from the thousand. The five European locales all put the symbol last, and all of them put a no-break space before it: French produces the amount with narrow no-break spaces in the digits and then a no-break space and the EUR symbol; German produces 1.234,50 followed by that space and the symbol; Spanish, Portuguese and Italian produce 1234,50 followed by it, unaggregated because of the four-digit rule above.

That no-break space before the symbol is a real character and it is there on purpose: it stops a line break from separating the amount from its unit. It also breaks the same naive parsers as the group separator, and it makes string comparison against a hand-written expected value fail in tests for no visible reason. Compare formatted output with formatToParts, not with equality against a literal you typed.

Percent has its own trap, and it is not about separators. style: "percent" multiplies by 100 before formatting. Passing 12.34 because you already converted the ratio yields 1,234% — a number a hundred times too large, printed without complaint. Pass the raw ratio, 0.1234, and you get 12.34%. Spacing differs too: English writes 12.3% with no gap, French, Spanish and German insert a no-break space before the sign, and Portuguese and Italian do not.

Fraction digits, rounding, and the traps in the options

The default for a plain number is a maximum of three fraction digits, so formatting 1.23456 gives 1.235 and the rest is gone. For a currency the default comes from the currency itself: two digits for the dollar and the euro, zero for the yen, three for the Tunisian dinar. That is usually what you want, and it is worth knowing it is happening rather than assuming two everywhere.

The two options that cause support tickets are minimumFractionDigits and maximumFractionDigits. Setting the minimum above the maximum throws a RangeError rather than clamping, which is at least loud. Setting the minimum alone silently raises the maximum to match, so minimumFractionDigits: 4 on 1.23456789 prints 1.2346 rather than the two digits you may have been expecting from elsewhere in the codebase.

Rounding has a default worth knowing about. Intl rounds half away from zero, so 2.5 becomes 3 and -0.5 becomes -1 at zero decimals; pass roundingMode: "halfEven" and 2.5 becomes 2, which is what accounting and statistics usually want. And Intl is not toFixed: rounding 1.005 to two places gives 1.01 from Intl and 1.00 from toFixed, and rounding 2.675 gives 2.68 from Intl and 2.67 from toFixed. The difference is that toFixed rounds the binary double, whose value is very slightly below the decimal you wrote, while Intl rounds the decimal you meant.

Compact notation, which is a translation and not an abbreviation

Setting notation: "compact" turns 1234567 into 1.2M in English. The other five locales do not all use M: French gives the amount with an M, Spanish and Portuguese give M as well, German gives Mio. and Italian gives Mln. In long form the differences are plainer — 1.2 million, 1,2 million, 1,2 millones, 1,2 milhões, 1,2 Millionen, 1,2 milioni.

The thousands are messier still. English gives 1.5K for 1500, French gives 1,5 k with a lowercase k, Spanish and Portuguese give 1,5 mil, Italian gives 1,5K — and German gives 1500, unchanged, because CLDR has no short compact form for thousands in German. If your dashboard assumes every locale shortens the same way, the German column will be wider than the others and there is nothing to configure about it.

Why parseFloat cannot undo any of it

toLocaleString is a one-way function. We formatted 1234567.891 in each of the six locales and fed the result straight back to Number.parseFloat. English returned 1. French returned 1. Portuguese returned 1. Spanish, German and Italian returned 1.234. Not one returned the original value, and the last three are the dangerous ones because 1.234 is a perfectly plausible number that no validation will reject.

Number() is at least honest: it returns NaN for all six, because none of them is a valid numeric literal. That makes Number() the better guard if you are only checking whether a string is a bare machine number, and it makes it useless as a parser of anything a human read.

The order of removal matters more than people expect. Take the German string 1.234.567,891. Strip the full stops first, then turn the comma into a full stop, and you get 1234567.891 — correct. Turn the comma into a full stop first and then strip full stops, and you get 1234567891, which is a thousand times too large and still a plausible integer. Both are two-line functions and only one is right.

We wrote the locale-driven version and tested it against all six locales: read the group and decimal characters from formatToParts, delete every occurrence of the group character, replace the decimal character with a full stop, drop anything left that is not a digit or a sign, then convert. It reproduced the original value exactly in all six, and it also parsed the six currency strings, symbols and no-break spaces included, without any locale-specific code.

1234567.891
Intl.NumberFormat output for 1234567.891 and for a money amount of 1234.50 — Node 26.3.0, ICU 78.3
Locale1234567.891Group separatorDecimal separatorMoney, 1234.50
en-US1,234,567.891CommaFull stop$1,234.50 (symbol first)
fr-FR1 234 567,891U+202F narrow no-break spaceComma1 234,50 EUR (symbol last)
es-ES1.234.567,891Full stopComma1234,50 EUR (no grouping below 10,000)
pt-PT1 234 567,891U+00A0 no-break spaceComma1234,50 EUR (no grouping below 10,000)
de-DE1.234.567,891Full stopComma1.234,50 EUR (symbol last)
it-IT1.234.567,891Full stopComma1234,50 EUR (no grouping below 10,000)
Number formatterAdd thousands separators and set decimal places for the numbers in your text.Try the tool

Frequently asked questions

Which separator does French actually use for thousands?
U+202F NARROW NO-BREAK SPACE, in the CLDR data current at the time of writing — we read it straight out of formatToParts on Node 26 with ICU 78. It is not the space bar and not U+00A0, which is what European Portuguese uses. Older CLDR releases used U+00A0 for French too, so any code that hard-codes one of them will break on a runtime upgrade. Read the separator at runtime and the question stops mattering.
Why does 1234 print without a separator in Spanish and Italian?
Because CLDR sets those locales' minimum grouping digits to two, which means grouping only begins once there are at least two digits before the first separator. So 1234 is written plain and 10.000 is grouped. It is deliberate: four-digit numbers in those languages are often years or reference numbers. Pass useGrouping: "always" if you need the separator anyway, for instance to keep a column of figures aligned.
Can I use toLocaleString and parseFloat as a pair?
No, and the failure is quiet. We formatted 1234567.891 in six locales and ran parseFloat on each result: three returned 1 and three returned 1.234. None returned the original. Number() at least returns NaN for all six, so it fails loudly. Formatting is for display and parsing needs its own code path driven by the locale's separators.
Should I store formatted numbers or raw ones?
Raw, always, and format at the last possible moment. A stored value of 1234567.891 is unambiguous and arithmetic works on it. A stored string of 1.234.567,891 carries a language you then have to remember, will not sort numerically, and turns every calculation into a parse. The formatted form belongs in the view layer, generated per request from the reader's locale.
Why does toFixed(2) disagree with Intl on 1.005?
Because they round different things. The double-precision value closest to 1.005 is very slightly less than 1.005, and toFixed rounds that binary value, so it produces 1.00. Intl rounds the decimal number you asked for and produces 1.01. The same split shows up on 2.675, where toFixed gives 2.67 and Intl gives 2.68. If money is involved, use Intl or an exact decimal type, and never mix the two in one report.
Is a bare language code enough for Intl?
Not always, and Portuguese is the clearest example. Asking Intl for "pt" gives Brazilian conventions — a full stop for thousands, 1.234.567,891 — because that is where the majority of Portuguese speakers are, while "pt-PT" gives the European form with a no-break space. English behaves the same way in reverse for date and measurement conventions. If your audience for a language is a specific country, name the country in the tag.

Articles you may find interesting

All guides
ExplainerCounting Words Is Ambiguous, and Every Tool Answers DifferentlyA word count is a definition, not a measurement. We counted the same paragraph four ways and got 25, 28, 33 and 38 — then counted 50,000 characters of ordinary prose and got agreement to within 4.5%. The gap is entirely driven by compounds, figures and URLs.ExplainerEmoji Are Harder Than They Look: Why "Just Strip the Emoji" Has No One-Line AnswerOne visible emoji can be one code point or fourteen UTF-16 units. We ran three popular regexes against a real sentence and each broke differently — one deleted the digits. Here is why, which Unicode property answers which question, and the grapheme-cluster rule that actually works.How-toFiltering Lines by a Pattern Without a Command LineThis is grep for people who do not use grep, with one important difference: the match is a plain substring, so a real regular expression returns an empty box and no error. Every claim here was checked by running the tool.ExplainerWhere a Line May Break: The Unicode Algorithm Behind Every Wrapped Paragraph"Break at spaces" fails in most of the world's writing systems. UAX #14 gives every character a line-break class; we looked ours up in Unicode 17.0.0 and ran a conforming implementation over no-break spaces, soft hyphens, zero-width spaces, URLs, Japanese and Thai.GuideConverting Between List Formats Without Losing Data: The Quoting Rules Nobody ReadsTurning a newline list into a comma list is trivial until an item contains a comma. RFC 4180's quoting rules, why a CSV field may contain a newline, why European spreadsheets use the semicolon, and what an empty item does to a round trip — every case run and printed.GuideStripping Markdown: What Plain Text Loses, and What a Regex Gets WrongA link becomes text with its destination deleted, a nested list loses its hierarchy, a table becomes a row of words. Then the technical half: markdown has no single spec, and a regex stripper mangles a filename, a multiplication sign and the inside of a code block — all shown against a real parser.

Related tools

Sources

Spotted a mistake in this article?