How to check the thousand separator and decimal separator
Format one number in every locale you ship, then read the separators as code points with formatToParts. In this run fr-FR grouped with U+202F, a narrow no-break space, ru-RU with U+00A0, and de-CH with an apostrophe. Two of the seven used the comma an English test expects, and one of those grouped in twos.
Why check this
Run this when a locale is added, and again in regression after any change to a formatting helper or to the ICU version a build pins. Separators are decided by CLDR data, not by your code, so a dependency bump moves them without a line of your source changing.
Two failures come out of it. A test compares a rendered amount against a string a developer typed by hand, the two look identical on screen, and the assertion fails with no visible difference to explain it. Or a parser reads a German figure back with parseFloat, gets a number one thousand times too small, and writes it to the database without an error anywhere.
Prerequisites
- Node 22 with full ICU, or the console of any modern browser. This machine ran Node 22.23.2 with ICU 78.2 and CLDR 48.
- The Intl.NumberFormat reference for the option names used below.
- The list of locales the product actually ships. Testing the ones ICU happens to know is a different exercise.
- A number with grouping and a fraction. Every run below uses 1234567.89.
Steps
- Step 1.
Format the same value in each locale and look at the seven results together.
// fmt.mjs const n = 1234567.89; for (const l of ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'en-IN', 'ru-RU', 'sv-SE']) { console.log(l.padEnd(6), JSON.stringify(new Intl.NumberFormat(l).format(n))); }en-US "1,234,567.89" de-DE "1.234.567,89" fr-FR "1 234 567,89" de-CH "1'234'567.89" en-IN "12,34,567.89" ru-RU "1 234 567,89" sv-SE "1 234 567,89"Four separators appear: comma, full stop, apostrophe and something that looks like a space. en-IN groups the top digits in twos.
- Step 2.
Read the separators as code points, because the screen cannot tell two spaces apart.
// parts.mjs const cp = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' '); for (const l of ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'ru-RU', 'sv-SE']) { const p = new Intl.NumberFormat(l).formatToParts(1234567.89); console.log(l.padEnd(6), 'group', cp(p.find((x) => x.type === 'group').value).padEnd(6), 'decimal', cp(p.find((x) => x.type === 'decimal').value)); }en-US group U+002C decimal U+002E de-DE group U+002E decimal U+002C fr-FR group U+202F decimal U+002C de-CH group U+0027 decimal U+002E ru-RU group U+00A0 decimal U+002C sv-SE group U+00A0 decimal U+002CThe two spaces are different characters. fr-FR uses U+202F, a narrow no-break space. ru-RU and sv-SE use U+00A0. Neither is the U+0020 on your keyboard.
- Step 3.
Compare the rendered string with an expectation typed by hand.
// compare.mjs const actual = new Intl.NumberFormat('fr-FR').format(1234567.89); const expected = '1 234 567,89'; // typed with a plain space console.log('equal ', actual === expected); console.log('length ', actual.length, expected.length); const i = [...actual].findIndex((c, k) => c !== expected[k]); console.log('mismatch at index', i, 'got U+' + actual.codePointAt(i).toString(16).toUpperCase(), 'want U+' + expected.codePointAt(i).toString(16).toUpperCase().padStart(4, '0'));equal false length 12 12 mismatch at index 1 got U+202F want U+0020Same length, same glyph width on screen, different character. A diff in a test report shows two identical lines.
- Step 4.
Feed each localized string back to the two parsers people reach for.
// parse.mjs for (const l of ['de-DE', 'en-US']) { const s = new Intl.NumberFormat(l).format(1234.56); console.log(l, 'string', s, '| parseFloat', parseFloat(s), '| Number', Number(s)); }de-DE string 1.234,56 | parseFloat 1.234 | Number NaN en-US string 1,234.56 | parseFloat 1 | Number NaNparseFloatstops at the first character it does not recognise and keeps what it has. The German amount comes back as 1.234, which is a valid number, a thousand times too small, and silent.Numberat least refuses. - Step 5.
Parse the string back using the separators the locale itself reports.
// roundtrip.mjs function parseLocaleNumber(text, locale) { const p = new Intl.NumberFormat(locale).formatToParts(12345.6); const group = p.find((x) => x.type === 'group').value; const decimal = p.find((x) => x.type === 'decimal').value; return Number(text.split(group).join('').replace(decimal, '.')); } for (const l of ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'en-IN', 'ru-RU']) { const s = new Intl.NumberFormat(l).format(1234567.89); const back = parseLocaleNumber(s, l); console.log(l.padEnd(6), s.padEnd(14), '->', back, back === 1234567.89 ? 'ok' : 'FAIL'); }en-US 1,234,567.89 -> 1234567.89 ok de-DE 1.234.567,89 -> 1234567.89 ok fr-FR 1 234 567,89 -> 1234567.89 ok de-CH 1'234'567.89 -> 1234567.89 ok en-IN 12,34,567.89 -> 1234567.89 ok ru-RU 1 234 567,89 -> 1234567.89 okNo character literals anywhere. The same function handles the Indian grouping and the two kinds of space, because it never guesses what the separators are.
- Step 6.
Check how many fraction digits the default keeps, before trusting a rendered amount.
// maxfrac.mjs const v = 1234.56789; console.log('default ', new Intl.NumberFormat('en-US').format(v)); console.log('maximumFractionDigits', new Intl.NumberFormat('en-US').resolvedOptions().maximumFractionDigits); console.log('two decimals ', new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v));default 1,234.568 maximumFractionDigits 3 two decimals 1,234.57The default keeps three fraction digits and rounds the rest away. A money amount rendered without explicit digit options is rounded by a rule nobody chose.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| group is U+00A0 or U+202F | The locale groups with a no-break space | Stop comparing against hand-typed strings. Assert on formatToParts output instead. |
| decimal is U+002C | The decimal separator is a comma | Every parser on that path needs the locale. parseFloat will truncate. |
| Grouping appears in twos, as in en-IN | The locale uses a 2-2-3 pattern | A test counting groups of three reports a defect that does not exist. |
| A four-digit number has no group separator | The locale needs five digits before it groups | Verify with a five-digit value before filing it. |
| maximumFractionDigits is 3 | No digit options were passed | Set minimumFractionDigits and maximumFractionDigits for any amount that is money. |
Common mistakes
What to check next
- How to test currency formatting: the same separators plus symbol position and fraction digits that change per currency.
- How to test date format in different locales: the other half of the CLDR data the same build pulls in.
- How to change locale in chrome: runs these formats through a real browser instead of Node.
- How to check the browser locale: which tag the page ends up using when nobody passes one.
- How to test pluralization: the counted noun next to the number you have now verified.
FAQ
How do I check number formatting without writing a script?
Open any browser console and run new Intl.NumberFormat('de-DE').format(1234567.89). For the separators themselves, call formatToParts instead of format and read the group and decimal entries.
Which locale is used when I pass none?
The runtime default. new Intl.NumberFormat().resolvedOptions().locale returned uk-UA on this machine, which is the operating system setting. A test that omits the locale tests the machine it runs on.
Can I turn grouping off for a test fixture?
Yes. new Intl.NumberFormat('de-DE', { useGrouping: false }).format(1234567.89) returned 1234567,89 here. The decimal separator still follows the locale, so the string is not a plain number.
Is the separator the same in Node and in the browser?
Both read CLDR data through ICU, so the values agree when the ICU versions do. Read process.versions.icu, which printed 78.2 here, and compare it against the browser build before treating any difference as a defect in your code.
Should the API send formatted numbers?
Send the unformatted value and format at the edge. An API that sends 1 234 567,89 forces every consumer to parse a locale it was not told about, which is the failure in step 4.
Verified
Verified by Maks VernyNode 22.23.2ICU 78.2, CLDR 48
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic6 minpublished updated Maks Verny