Floating Point, Finally
Almost every programmer eventually types 0.1 + 0.2 into a console and gets back 0.30000000000000004, shrugs, and moves on without ever learning why. The honest answer is that floating point isn’t broken — it’s doing exactly what its 1985 specification, IEEE 754, defines it to do, and that specification is a deliberate, carefully reasoned trade-off between range, precision, and hardware cost, not an accident. Understanding the actual bit layout underneath a float or double explains not just the 0.1-plus-0.2 party trick, but every real numerical bug that has ever shipped because of it — including a 1991 rounding error in a 24-bit military clock register that let a Scud missile through an active Patriot missile defense battery.
Binary Scientific Notation, Bit for Bit
A floating-point number is scientific notation, implemented in binary instead of decimal, packed into a fixed number of bits. The IEEE 754 standard defines this packing precisely for the common formats: single precision (float, 32 bits total) splits into a 1-bit sign, an 8-bit exponent, and a 23-bit fraction (also called the mantissa or significand); double precision (double, 64 bits total) uses 1 sign bit, 11 exponent bits, and 52 fraction bits.
32-bit single precision (float)
┌─┬────────┬───────────────────────┐
│S│Exponent│ Fraction │
└─┴────────┴───────────────────────┘
1 8 23 = 32 bits
value = (-1)^S × 1.fraction × 2^(exponent - 127)
↑
bias, so exponent
can represent both
positive and negative
powers of two
The exponent field is stored with a bias added (127 for single precision, 1023 for double) specifically so the hardware can compare two floating-point bit patterns as if they were plain unsigned integers and get the correct ordering, without needing separate logic for negative exponents. The fraction field encodes the digits after an implicit leading 1. — normalized floating-point numbers always have exactly one nonzero digit before the binary point, the same way normalized decimal scientific notation always writes a single digit before the decimal point (6.022 × 10²³, never 60.22 × 10²²), and because that leading digit is always 1 for a normal binary float, the standard doesn’t even bother storing it, getting one extra bit of precision for free.
| Format | Total bits | Sign | Exponent | Fraction | Approx. decimal precision |
|---|---|---|---|---|---|
Single (float) |
32 | 1 | 8 | 23 | ~7 decimal digits |
Double (double) |
64 | 1 | 11 | 52 | ~15–17 decimal digits |
Why 0.1 + 0.2 Isn’t 0.3
The core problem is that binary floating point can only exactly represent numbers that are sums of powers of two — and most decimal fractions people write casually, including 0.1 and 0.2, simply aren’t sums of powers of two, the same way 1/3 has no finite decimal representation (0.333…) even though it’s a perfectly simple fraction in base 3. In binary, 0.1 works out to the repeating pattern 0.0001100110011..., going on forever, and since a double only has 52 fraction bits to work with, that infinite repeating pattern gets truncated and rounded to the nearest representable value — a value that is close to but not exactly 0.1.
The same thing happens to 0.2. When the computer adds these two already-slightly-wrong stored values together, the result is the sum of two small rounding errors, and that sum happens to round to a stored value that, when printed back out in decimal, displays as 0.30000000000000004 rather than a clean 0.3. Nothing is malfunctioning — the arithmetic is completely correct given what’s actually stored; the surprise is purely that the decimal literals 0.1 and 0.2 were never stored exactly in the first place.
|
|
This is precisely why financial and monetary calculations are conventionally done with fixed-point decimal types (like Python’s decimal.Decimal, or scaled integer cents) rather than binary floating point — a currency system built on float will eventually accumulate visible rounding discrepancies purely from values like $0.10 and $0.20 never being exactly representable in the first place.
The Special Values: Infinity, NaN, and Signed Zero
IEEE 754 reserves specific bit patterns to represent values outside the normal numeric range, and these aren’t edge-case afterthoughts — they’re load-bearing parts of how the standard lets computation continue sensibly instead of crashing on an undefined result. An exponent field of all 1s with a zero fraction represents positive or negative infinity, the well-defined result of operations like dividing a nonzero number by zero. An exponent field of all 1s with a nonzero fraction represents NaN (“Not a Number”) — the result of an operation with no sensible numeric answer, like 0.0 / 0.0 or sqrt(-1.0) in real-valued arithmetic.
NaN has one famously counterintuitive property baked directly into the standard: by definition, NaN does not equal anything, including itself. x != x evaluating to true is, in fact, the standard-prescribed way many languages and libraries detect whether a floating-point value is NaN in the first place, precisely because NaN comparisons are defined to always return false for equality and true for inequality, regardless of what produced the NaN. Any arithmetic operation that takes a NaN as an input is required to produce NaN as output too, which is a deliberate design choice: it lets an error propagate visibly through a long calculation chain rather than silently vanishing into a plausible-looking but wrong number.
IEEE 754 also defines a signed zero — positive zero and negative zero are distinct bit patterns that compare as numerically equal but can produce different results in specific operations (1.0 / +0.0 yields positive infinity; 1.0 / -0.0 yields negative infinity), which matters in numerical code that tracks the direction a value approached zero from.
Denormals: The Gap-Filler Nobody Asks For Until They Need It
Normal floating-point numbers always have that implicit leading 1. before the fraction bits, which works well until you get close enough to zero that even the smallest representable exponent isn’t small enough. IEEE 754 fills that final gap with subnormal (denormal) numbers: when the exponent field is all zeros, the format drops the implicit leading 1 and instead treats the fraction as representing a value with a fixed, minimal exponent, allowing numbers to shrink gradually toward zero instead of abruptly jumping straight to it once the normal range is exhausted — a property called gradual underflow.
The trade-off is real and shows up in two places. Precision degrades progressively as values move deeper into subnormal range, because fewer of the fraction bits remain meaningful the smaller the represented value gets relative to the format’s minimum normal exponent — so a computation that drifts into denormal territory is quietly losing precision even though it hasn’t hit an outright error. And historically, denormal arithmetic has carried a severe hardware performance penalty on many processors, because early floating-point units handled the normal case in fast dedicated silicon and fell back to slow microcode or trap-and-emulate handling for denormals — documented slowdowns on some processor generations have run to well over 100x versus normal-range arithmetic for the exact same operation, purely because the operand happened to be denormal.
Catastrophic Cancellation: When Subtraction Eats Your Precision
The single most dangerous everyday floating-point trap isn’t rounding error in isolation — it’s what happens when you subtract two floating-point numbers that are very close to each other in value. Each of the two numbers may individually be a perfectly good approximation, correct to the full precision the format allows. But when they’re nearly equal, subtracting them cancels out most of the significant digits they share, leaving a result whose remaining digits are dominated by whatever rounding error was already present in the two inputs — the answer can end up with almost none of the precision either original number had.
a = 1.234567891234560 (16 significant digits, mostly accurate)
b = 1.234567891234000 (16 significant digits, mostly accurate)
a - b = 0.000000000000560
Only the LAST few digits of a and b were actually reliable —
subtracting the large, nearly-identical leading digits cancels
them out and leaves a result built almost entirely from
whatever rounding noise was already present in a and b.
This is called catastrophic cancellation, and it’s “catastrophic” specifically because the failure is silent — the computation doesn’t crash, throw an error, or produce a NaN; it just quietly returns a number that looks plausible but has lost most or all of its meaningful precision. It shows up in real numerical code constantly: computing the variance of a dataset via the naive sum(x²)/n - mean² formula is a classic example, because for data clustered tightly around its mean, the two large terms being subtracted are nearly equal, and the true variance — a small number — gets computed as the difference of two large, nearly-canceling quantities. The standard fix is algorithmic, not a bigger data type: rewrite the computation to avoid subtracting nearly-equal quantities in the first place (a two-pass variance calculation, or techniques like Kahan summation for accumulating long sums with compensated rounding), rather than hoping more precision bits will paper over a fundamentally unstable formula.
When It Actually Kills People: The Patriot Missile
The clearest real-world demonstration of floating-point rounding error having stakes beyond a wrong console output is the February 25, 1991 Patriot missile battery failure at Dhahran, Saudi Arabia, during the Gulf War, which failed to intercept an incoming Iraqi Scud missile that struck a U.S. Army barracks, killing 28 soldiers.
The root cause traced back to how the Patriot system’s software tracked time. The battery’s internal clock counted time as an integer number of tenths of a second, and to use that value in the tracking software’s real-number calculations, it had to be converted to a floating-point representation — but the conversion of 0.1 (one-tenth of a second) into the system’s 24-bit fixed-point binary format was itself an approximation, for exactly the same underlying reason 0.1 can’t be represented exactly in any binary format: it isn’t a sum of powers of two. That tiny per-tick error was small enough to be harmless over short running times, but it accumulated linearly the longer the system stayed powered on without a restart. After roughly 100 continuous hours of operation — this specific battery had been running for that long, when standard procedure assumed far shorter continuous runtimes — the accumulated timing error had grown to approximately 0.34 seconds. That may sound trivial, but a Scud missile travels roughly 1,676 meters per second, so a 0.34-second timing error translated into the system’s radar tracking software calculating the incoming missile’s expected position more than half a kilometer away from where it actually was. The system’s range gate — the software window used to keep tracking a detected target — shifted enough that it lost the incoming Scud entirely and never engaged it.
| Detail | Value |
|---|---|
| Clock storage format | 24-bit fixed-point integer, units of 0.1 second |
| Root cause | 0.1 second cannot be represented exactly in binary, so each clock tick accumulated a tiny rounding error |
| Accumulated error after ~100 hours uptime | ~0.34 seconds |
| Scud missile speed | ~1,676 m/s |
| Resulting tracking position error | 500+ meters |
| Outcome | Range gate lost the incoming target; no intercept attempted; 28 killed |
A software patch that corrected the time conversion had already been written and was en route to Dhahran, but it arrived one day after the attack. The incident remains one of the most frequently cited real-world case studies in numerical computing courses specifically because the underlying defect wasn’t exotic — it was the exact same binary-fraction rounding behavior every programmer encounters typing 0.1 + 0.2 into an interpreter, just compounded over a hundred hours of uptime in a system where the consequence of a half-kilometer tracking error was measured in human lives rather than a mismatched cent in an invoice total.
Honest Trade-offs
- More precision bits don’t fix an unstable algorithm. Switching from
floattodouble, or even to extended/quad precision, delays when catastrophic cancellation becomes visible but doesn’t eliminate the underlying instability in a poorly chosen formula — the correct fix is almost always a numerically stable reformulation, not a wider data type. - Denormals trade correctness for speed, and you often can’t have full amounts of both. Flushing denormals to zero (a common compiler/hardware fast-math setting) avoids the performance cliff but silently discards the gradual-underflow behavior IEEE 754 designed in specifically to avoid abrupt precision loss near zero — a real accuracy-versus-performance trade a numerical codebase has to choose deliberately, not by default compiler flag accident.
- Equality comparisons on floats are close to a category error. Because most decimal values aren’t exactly representable, direct
==comparison between floating-point results is rarely the right tool; comparing within a tolerance (an epsilon) is the standard workaround, but choosing that epsilon correctly depends on the magnitude of the values involved, which is its own frequently-mishandled subtlety. - NaN propagation is a feature, but it can mask exactly where a computation first went wrong. Because NaN silently propagates through nearly all downstream arithmetic, a NaN discovered at the end of a long pipeline tells you a failure happened somewhere upstream, but not where — debugging typically requires deliberately checking intermediate values rather than trusting the final NaN to point at its own origin.
Verdict
Floating point isn’t an approximation bolted onto computers as an afterthought — it’s a precisely specified, deliberately engineered trade-off between representable range and available precision, encoded bit-for-bit in a format that hasn’t fundamentally changed since 1985. Every well-known floating-point “gotcha” — 0.1 plus 0.2, NaN not equaling itself, a subtraction that quietly destroys twelve digits of precision — is a direct, predictable consequence of that binary-fraction representation, not a bug in any individual program. The Patriot missile failure is the sobering proof that this isn’t purely academic: the exact same rounding behavior that produces a slightly odd console output in a beginner’s first Python session can, given the right accumulation window and the right consequence attached to the result, become the difference between an intercepted missile and a barracks full of soldiers who never got a warning.
Sources
- W3Tutorials — IEEE Standard 754 Floating Point Numbers: A Deep Dive
- Built In — Why 0.1 + 0.2 Doesn’t Equal 0.3 in Programming
- Wikipedia — Catastrophic Cancellation
- UC Berkeley — Basic Issues in Floating Point Arithmetic and Error Analysis
- kindatechnical — Subnormal Numbers and Gradual Underflow
- Random ASCII — That’s Not Normal: The Performance of Odd Floats
- University of Minnesota — Roundoff Error and the Patriot Missile
- University of North Carolina — Patriot Missile Software Problem
- University of Texas — Disasters Due to Rounding Error
Comments