~/blog

parseFloat('1,234.56') returns 1, not 1234.56

published

#javascript#i18n#numbers#parsing

TL;DR

parseFloat('1,234.56') is 1. parseFloat reads a number from the start of the string and stops at the first character that cannot continue it, and a comma cannot. Nothing throws and the result is a valid number, so the bug shows up as wrong totals, not as an error. Number('1,234.56') returns NaN, which at least fails loudly. To parse formatted input properly, ask Intl.NumberFormat.prototype.formatToParts() which group and decimal separators the locale uses, and reject anything that does not match that shape instead of keeping the part that does.

The problem

Prices come in from a spreadsheet export, a scraped page or a form, already formatted for people:

const prices = ['1,299.00', '849.50', '2,150.00'];
const total = prices.map(parseFloat).reduce((a, b) => a + b, 0);
console.log(total);

Output on Node 26.2.0:

852.5

The real total is 4,298.50. parseFloat turned '1,299.00' into 1 and '2,150.00' into 2, kept 849.50 intact because it has no comma, and added them up. Every value is a finite number, so a check like Number.isNaN passes, and any amount under 1,000 parses correctly, which is exactly what a quick test with small numbers exercises.

Why it happens

parseFloat is defined to be lenient. It skips leading whitespace, reads the longest prefix that forms a decimal literal, and ignores everything after it. MDN puts it plainly: parseFloat() “ignores trailing invalid characters, which would cause Number() to return NaN.” A comma is not part of a JavaScript numeric literal, so it ends the number:

console.log(parseFloat('1,234.56'));
console.log(Number('1,234.56'));
console.log(parseFloat('1.234,56'));
console.log(parseFloat('$1,299'));
console.log(parseFloat('12 345,6'));
1
NaN
1.234
NaN
12

The German-style '1.234,56' is worse than the US-style one: the dot is a valid decimal point, so you get 1.234, a plausible-looking number that is a thousand times too small. A leading currency sign gives NaN, because the very first character is already invalid. A French-style space stops the number just like a comma does.

Number() uses the stricter string-to-number conversion: the whole string, minus surrounding whitespace, has to be a valid numeric literal or the result is NaN. That is the better default for input you did not produce yourself, but on its own it only tells you the input is not a plain JavaScript number. It cannot read '1,234.56' either.

The separators depend on the locale

There is no single “remove the commas” fix, because the comma means different things in different places. formatToParts() shows what each locale uses:

const hex = (c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');

for (const locale of ['en-US', 'es-MX', 'de-DE', 'fr-FR', 'es-ES', 'de-CH']) {
  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
  const group = parts.find((p) => p.type === 'group').value;
  const decimal = parts.find((p) => p.type === 'decimal').value;
  console.log(locale.padEnd(6), new Intl.NumberFormat(locale).format(12345.6).padEnd(10), 'group', hex(group), 'decimal', decimal);
}
en-US  12,345.6   group U+002C decimal .
es-MX  12,345.6   group U+002C decimal .
de-DE  12.345,6   group U+002E decimal ,
fr-FR  12 345,6   group U+202F decimal ,
es-ES  12.345,6   group U+002E decimal ,
de-CH  12'345.6   group U+0027 decimal .

Mexico and the US write numbers the same way; Spain and Germany swap the roles of . and ,. French uses U+202F, a narrow no-break space, which is not the space a person types. And a string like '1,234' is one thousand two hundred thirty-four in en-US and one point two three four in de-DE. Without knowing the locale, the input is ambiguous.

What to do

1. Use Number(), not parseFloat, for anything that must be a whole number string. If the input should be a plain number like 1234.56, Number() gives you NaN for everything else, and you can reject it. Reserve parseFloat for the rare case where you really do want a leading number out of text such as '12px'.

2. For formatted input, parse against the locale’s own separators. Intl.NumberFormat formats but has no parse method, so build the parser from formatToParts(). The important part is to validate the whole string, including where the group separators sit, rather than stripping characters and hoping:

function parseLocaleNumber(text, locale) {
  const parts = new Intl.NumberFormat(locale).formatToParts(-12345.6);
  const sep = (type) => parts.find((p) => p.type === type).value;
  const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

  // fr-FR groups with U+202F, but people type a plain space: accept any whitespace.
  const group = /\s/.test(sep('group')) ? '\\s' : esc(sep('group'));
  const decimal = esc(sep('decimal'));
  const minus = `(?:-|−|${esc(sep('minusSign'))})`;

  // Either correctly grouped digits (1,234,567) or no grouping at all (1234567).
  const pattern = new RegExp(
    `^(${minus})?(\\d{1,3}(?:${group}\\d{3})+|\\d+)(?:${decimal}(\\d+))?$`,
  );
  const m = text.trim().match(pattern);
  if (!m) return NaN;

  const [, sign, int, frac] = m;
  const value = Number(int.replace(/\D/g, '') + (frac ? '.' + frac : ''));
  return sign ? -value : value;
}

console.log(parseLocaleNumber('1,234.56', 'en-US'));
console.log(parseLocaleNumber('1.234,56', 'de-DE'));
console.log(parseLocaleNumber('12 345,6', 'fr-FR'));
console.log(parseLocaleNumber('-1,299.00', 'es-MX'));
1234.56
1234.56
12345.6
-1299

It returns NaN for input that does not fit the locale, instead of salvaging a prefix:

console.log(parseLocaleNumber('$1,299', 'en-US'));
console.log(parseLocaleNumber('1,234.56', 'de-DE'));
console.log(parseLocaleNumber('1,2,3', 'en-US'));
NaN
NaN
NaN

The middle line is the reason for the grouping check. An earlier version of this function simply deleted the group separator and swapped the decimal one; for '1,234.56' in de-DE it deleted the dot, turned the comma into a decimal point and returned 1.23456, the same silent-wrong-number bug it was meant to fix.

With the locale given explicitly, the totals from the opening example come out right:

const fixed = prices.map((p) => parseLocaleNumber(p, 'es-MX')).reduce((a, b) => a + b, 0);
console.log(fixed);
4298.5

3. Decide where the locale comes from, and make it explicit. For a form, that is the locale you rendered the form in, not a guess from the value. For a file, it is a property of the export, so record it next to the data. '1,234' parses to 1234 in en-US and 1.234 in de-DE, and both are valid; no amount of cleverness in the parser can pick the right one for you.

4. Store and exchange numbers unformatted. Formatting belongs at the edge where a person reads the value. JSON, query strings and database columns should carry 1234.56, which Number() reads without any locale at all.

Caveats

References