parseInt(0.0000005) is 5, because parseInt reads a string
published
TL;DR
parseInt is a string parser. Hand it a number and it calls String() on it first, then reads digits until the first character that is not one. Very small and very large numbers stringify in exponent notation, so parseInt(0.0000005) reads "5e-7" and returns 5, and parseInt(1e21) reads "1e+21" and returns 1. To drop the fraction from a number, use Math.trunc. To turn a string into an integer, pass a radix and validate the whole string, because parseInt('12px') happily returns 12.
The problem
parseInt gets used as a shorthand for “drop the decimals”, and it works on every value you try by hand:
for (const x of [12.9, 0.5, 0.000005, 0.0000005, 1e20, 1e21, -0.0000004]) {
console.log(String(x).padEnd(24), 'parseInt ->', String(parseInt(x)).padEnd(22), 'Math.trunc ->', Math.trunc(x))
}
12.9 parseInt -> 12 Math.trunc -> 12
0.5 parseInt -> 0 Math.trunc -> 0
0.000005 parseInt -> 0 Math.trunc -> 0
5e-7 parseInt -> 5 Math.trunc -> 0
100000000000000000000 parseInt -> 100000000000000000000 Math.trunc -> 100000000000000000000
1e+21 parseInt -> 1 Math.trunc -> 1e+21
-4e-7 parseInt -> -4 Math.trunc -> -0
Four rows agree. Then a value that should truncate to zero comes back as 5, a negative one comes back as -4, and 1e21 comes back as 1. The first column already shows why: those three numbers print in exponent form. (The -0 in the last row is Math.trunc keeping the sign of a negative input; the caveats come back to it.)
The small end is the dangerous one, because small values are ordinary in real code: a progress ratio, a probability, a difference between two timestamps in seconds, a price delta after division. Nothing about the code changes between the row that works and the row that returns 5.
Why it happens
MDN states the first step plainly: “The parseInt function converts its first argument to a string, parses that string, then returns an integer or NaN.” And: “If parseInt encounters a character in the input string that is not a valid numeral in the specified radix, it ignores it and all succeeding characters and returns the integer value parsed up to that point.”
So the result depends on how the number prints. JavaScript switches to exponent notation for numbers whose magnitude is below 1e-6 or at least 1e21:
for (const x of [1e-6, 9.99e-7, 1.5e-7, 1e20, 1e21]) {
console.log(String(x).padEnd(24), '-> parseInt', parseInt(x))
}
0.000001 -> parseInt 0
9.99e-7 -> parseInt 9
1.5e-7 -> parseInt 1
100000000000000000000 -> parseInt 100000000000000000000
1e+21 -> parseInt 1
9.99e-7 becomes "9.99e-7", and parsing stops at the ., giving 9. Every number in those two ranges collapses to its first significant digit.
The same “parse a prefix, ignore the rest” rule is behind the string cases that surprise people:
console.log(parseInt('12px')) // 12
console.log(parseInt('1,234')) // 1
console.log(parseInt('1e3')) // 1
console.log(parseInt('0x1A')) // 26
console.log(parseInt('0b101')) // 0
console.log(parseInt('')) // NaN
'1e3' is a thousand to Number and one to parseInt. Without a radix, parseInt recognizes exactly one prefix; MDN again: “The only prefix that parseInt() recognizes is 0x or 0X for hexadecimal values — everything else is parsed as a decimal value if radix is missing.” So '0x1A' silently becomes 26 while '0b101' stops at the b and becomes 0.
The map(parseInt) variant
The radix being the second parameter produces the other famous result:
console.log(['10', '10', '10', '10'].map(parseInt))
[ 10, NaN, 2, 3 ]
map calls its callback with (element, index, array), so this is parseInt('10', 0), parseInt('10', 1), parseInt('10', 2) and parseInt('10', 3). A radix of 0 falls back to 10, radix 1 is out of range and gives NaN, and '10' in base 2 and base 3 is 2 and 3. Wrap the call so only the string goes in:
console.log(['10', '10', '10', '10'].map((s) => parseInt(s, 10)))
[ 10, 10, 10, 10 ]
What to do
For numbers, use Math.trunc. It works on the number itself, so it has no string step to go wrong. MDN is direct about it: “parseInt should not be used as a substitute for Math.trunc().” If you meant rounding rather than dropping the fraction, Math.floor, Math.ceil and Math.round are the tools, and they are not interchangeable with it: Math.floor matches Math.trunc only on non-negative numbers (Math.floor(-1.5) is -2), Math.ceil only on non-positive ones, and Math.round goes to the nearest integer.
For strings, always pass the radix. parseInt(s, 10) turns off the 0x guess (parseInt('0x1A', 10) is 0) and keeps the call safe when someone passes it to map.
When the whole string must be a number, validate it first. parseInt accepts a numeric prefix, which is the wrong contract for form fields, query parameters and config values. Number is stricter about trailing junk, but it has its own traps: Number('') and Number(' ') are both 0, Number('1e3') is 1000, and Number('0x1A') is 26. A small helper that says exactly what it accepts avoids all of them:
function parseIntStrict(input) {
const s = String(input).trim()
if (!/^[+-]?\d+$/.test(s)) return NaN
const n = Number(s)
return Number.isSafeInteger(n) ? n : NaN
}
for (const v of ['42', ' 42 ', '-7', '+3', '08', '12px', '1e3', '', '0x1A', '1,234', '9007199254740993']) {
console.log(JSON.stringify(v).padEnd(20), parseIntStrict(v))
}
"42" 42
" 42 " 42
"-7" -7
"+3" 3
"08" 8
"12px" NaN
"1e3" NaN
"" NaN
"0x1A" NaN
"1,234" NaN
"9007199254740993" NaN
The last row is the Number.isSafeInteger check doing its job. parseInt('9007199254740993') returns 9007199254740992 without complaint, because the value is past Number.MAX_SAFE_INTEGER and cannot be represented exactly. If you need integers that large, parse them with BigInt instead.
Caveats
Math.trunckeeps the sign of zero.Math.trunc(-0.4)is-0. It compares equal to0with===andJSON.stringifywrites it as0, butObject.is(-0, 0)isfalse,1 / -0is-Infinity, and Node’sconsole.logprints it as-0. That rarely matters; if it does, add+ 0to the result, since-0 + 0is0.- MDN’s small-end boundary is incomplete. The
parseIntpage says numbers “less than or equal to 1e-7” use exponential notation; per the spec’s Number::toString, every nonzero magnitude below1e-6does, so9.99e-7and5e-7print that way too. - Leading zeros are decimal, not octal.
parseInt('08')is8; MDN says it does “not treat strings beginning with a0character as octal values”. Old advice to pass a radix “because of octal” is outdated, but passing one is still right for the0xandmapreasons. parseIntStrictonly accepts ASCII digits. In JavaScript\dmatches0to9only, with or without theuandvflags, so full-width and Arabic-Indic digits are rejected. If you parse input typed in scripts that use them, convert those digits first.- Everything above was run on Node v26.2.0. The behavior is defined by the language spec, so browsers produce the same values.