Contents

What Floating Point Is Doing Behind Your Back

0.1 plus 0.2 has never equalled 0.3, and it's not a bug

Contents

The first time a junior developer I was mentoring showed me a bug report titled “money is wrong by one penny,” I already knew the answer before opening the code, because I’d filed nearly the same bug against myself years earlier. Somewhere in the codebase, a price was stored as a float or double, and floating point arithmetic doesn’t represent most decimal fractions exactly, so a sum that should land on 19.99 lands on 19.990000000000002 instead, and depending on how it gets displayed or compared, that tiny discrepancy either shows up as a cosmetic glitch or as a genuinely wrong invoice. Understanding why this happens — and it’s not a bug in any implementation, it’s a structural property of the format — saves you from reaching for the wrong fix and from repeating the mistake in the next project.

Binary Fractions Don’t Line Up With Decimal Ones

Advertisement

A float or double stores a number as a sign, an exponent, and a mantissa — roughly, a value expressed as (some fraction) times (2 raised to some power), the same idea as scientific notation but in binary instead of decimal. This representation is exact for any number that can be written as a sum of powers of two: 0.5, 0.25, 0.125, and combinations of those, all store perfectly, with no rounding involved at all. The problem is that almost none of the decimal fractions people actually write down — 0.1, 0.2, 0.3, most currency amounts — happen to be expressible that way, for the same reason that 1/3 has no exact finite representation in decimal even though it has one in base three. In binary, 0.1 is an infinitely repeating fraction, and a 64-bit double only has room for 52 bits of mantissa, so the value actually stored is only ever the closest 52-bit binary approximation available to 0.1.

1
2
3
4
5
$ python3
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

That second line is the actual, exact value Python is storing when you type 0.1: the closest double-precision approximation available, printed out to its full precision instead of the rounded display value Python normally shows you. Every language using IEEE 754 doubles — which is almost all of them, since it’s a hardware-level standard most CPUs implement directly — stores the same approximation, because the imprecision lives in the format itself, a property shared identically across every language built on top of it. This is why the same “0.1 + 0.2” surprise shows up identically in JavaScript, Python, Java, C, and Rust: they’re all deferring to the same underlying binary representation, and none of them can escape it without deliberately not using it.

Why This Is a Feature, Not an Oversight

Floating point trades exactness for an enormous representable range and consistent relative precision across that range, using a fixed number of bits, which is exactly the trade-off you want for scientific computation, graphics, physics simulation, and most numerical work. A double can represent values from roughly 10^-308 to 10^308, with about 15-17 significant decimal digits of precision at any given magnitude, which is why it’s the default numeric type in most languages and why it’s the right choice for the overwhelming majority of numeric code that isn’t handling money. The format was standardised as IEEE 754 specifically so that hardware, compilers, and languages would all agree on rounding behaviour, meaning a calculation gives the same result on different machines — an unglamorous but genuinely valuable guarantee that took the industry years of incompatible implementations to actually achieve.

The trouble only starts when this general-purpose numeric type gets used for a job with a different requirement: exact decimal arithmetic, where 19.99 needs to mean exactly 19.99 every time, not “the closest double to 19.99,” because financial systems, invoicing, and anything involving auditors care about exactness in decimal terms, not approximate closeness in binary terms. Floating point was never designed to solve that problem and doesn’t claim to; the bug isn’t in the format, it’s in choosing the wrong tool for a job with a stricter requirement than the tool provides.

Where It Actually Bites in Practice

Advertisement

Beyond the classic “sum of prices doesn’t add up,” floating point imprecision causes a specific class of comparison bug: checking if (total == 20.00) after a calculation that should mathematically produce exactly 20.00 will often fail, because the accumulated rounding from several intermediate operations lands on 19.999999999999996 or 20.000000000000004 instead. Anyone who’s debugged a loop that “should have terminated” after exactly ten iterations of adding 0.1, only to find it ran eleven times or nine, has hit this directly — the accumulated rounding error eventually crosses a comparison boundary the code assumed was exact.

1
2
3
4
5
total = 0
for _ in range(10):
    total += 0.1
print(total == 1.0)   # False
print(total)           # 0.9999999999999999

Database systems handle this by offering a genuinely different numeric type for exactly this case — DECIMAL or NUMERIC in Postgres and MySQL, which stores digits directly in a base-10-friendly internal representation rather than binary, at the cost of being slower and taking more space than a native float. Anywhere money, quantities with legal significance, or anything an accountant will eventually reconcile by hand is involved, DECIMAL is the correct column type, and choosing FLOAT or DOUBLE instead is one of the more common and more expensive schema mistakes I’ve seen repeated across otherwise competent teams, usually because nobody thought about it at table-design time and it only surfaced as a rounding discrepancy months later in production.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- Wrong for money: accumulates binary rounding error
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  total FLOAT
);

-- Correct: exact decimal digits, no binary approximation
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  total NUMERIC(10, 2)
);

Most languages also ship a decimal type for the same reason — Python’s decimal.Decimal, Java’s BigDecimal, JavaScript’s newer BigInt for integer-only cases or a decimal library for fractional ones — and the rule of thumb I use is simple: if a human with a calculator and a strict sense of “this should add up exactly” would ever check the number, it belongs in a decimal type, not a float, from the very first line of code that touches it, not retrofitted after the first rounding bug report arrives.

NaN and Infinity Are Part of the Format, Not Errors

IEEE 754 reserves specific bit patterns for values that aren’t ordinary numbers at all: positive and negative infinity, for operations like dividing by zero, and “Not a Number” for operations with no sensible numeric result, like zero divided by zero or the square root of a negative number under real arithmetic. These aren’t crashes or exceptions in most languages — they’re valid values that propagate silently through further arithmetic, which is convenient for long numerical pipelines that shouldn’t halt on one bad input, and treacherous for anyone who doesn’t know to check for them explicitly, because a NaN can travel through dozens of calculations, log lines, and function calls before finally surfacing as a mysteriously blank chart or a database column full of nulls.

1
2
3
4
5
6
7
8
9
$ python3
>>> 0.0 / 0.0
Traceback (most recent call last):
ZeroDivisionError: float division by zero
>>> float('nan') == float('nan')
False
>>> import math
>>> math.isnan(float('nan'))
True

That second line is the one that catches people out repeatedly: NaN is defined by the standard to never equal anything, including itself, so if value == float('nan') is always false regardless of what value actually holds, and the only correct way to test for it is a dedicated function like math.isnan() or Number.isNaN(). I’ve seen this exact mistake ship to production as a silent data-quality filter that filtered out nothing, because the equality check it relied on could never be true no matter how much NaN-contaminated data flowed through it. Some languages additionally distinguish a “quiet” NaN from a “signalling” one, and Python’s own float('nan') == float('nan') behaviour above differs from 0.0/0.0 raising an exception purely because Python chooses to raise on the integer-like zero-division case rather than propagate a NaN — a language-level policy decision layered on top of the underlying format, not a property of IEEE 754 itself.

Precision Isn’t Constant Across the Number Line

One more property worth knowing before it surprises you: floating point precision is relative, not absolute, because the exponent-and-mantissa structure means the size of the smallest representable gap between adjacent values grows as the numbers themselves grow. Near zero, doubles can distinguish differences far smaller than a billionth; out past a few quadrillion, the gap between one representable double and the next can be larger than 1, meaning ordinary integer arithmetic on huge numbers silently loses precision if it’s done in floating point rather than an integer type. This is why summing a very large counter as a float, rather than an integer or a big-integer type, can eventually stop incrementing altogether — add 1 to a large enough double and the result rounds back down to the same value, because 1 has become smaller than the representable gap at that magnitude.

1
2
3
4
$ python3
>>> x = 2.0**53
>>> x == x + 1
True

That’s not a typo or a rounding artefact of one specific operation — it’s the double format running out of mantissa bits to distinguish x from x + 1 at that magnitude, and it happens deterministically at exactly 2^53 for doubles, a threshold worth knowing if you’re ever tempted to store a large counter, a timestamp in nanoseconds, or an auto-increment ID as a floating point number instead of an integer type built for exactly that job.

Troubleshooting: Spotting Floating Point Bugs Before They Ship

If a test intermittently fails on an equality check involving a calculated float, that’s the first and strongest signal — replace direct equality with a tolerance check (abs(a - b) < 0.0001) rather than assuming the test itself is flaky, because it usually isn’t; it’s correctly detecting that floating point equality was never a safe comparison to write. If a financial total is off by a cent somewhere in a report, grep the schema for FLOAT or DOUBLE columns storing currency before looking anywhere else, since that’s the overwhelmingly likely root cause and the fix (migrating to DECIMAL and reprocessing historical data) is disruptive enough that it’s worth confirming before starting. If you’re doing genuine scientific or graphics work and something looks numerically unstable over many iterations, look into compensated summation techniques (Kahan summation is the classic one) rather than switching numeric types entirely, since the precision you need is usually recoverable within floating point itself if the accumulation strategy is smarter about tracking the error term as it goes.

Is It Worth Understanding This?

Yes, and it’s one of the cheaper pieces of computer science trivia to actually internalise, because the fix is almost always a five-minute schema or type change once you know to look for it, versus a multi-day investigation if you don’t. Floating point isn’t broken; it’s doing exactly what IEEE 754 says it should, trading exact decimal representation for range and speed, and the only bug is using it somewhere that trade-off doesn’t fit the requirement. If this kind of “the spec is doing exactly what it says, the surprise is yours” story appeals to you, what eventual consistency is actually promising you is the distributed-systems version of the same lesson, and a database index is just a sorted list you paid for covers another case where understanding the underlying mechanism changes what you’d actually build.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.