Floating Point Pitfalls in Python
Almost every Python program that touches a
floatcarries a latent bug, and almost every developer first meets it as0.1 + 0.2 == 0.3evaluating toFalse. This is not a Python defect, a bug in the CPython interpreter, or rounding the print routine got wrong — it is the unavoidable consequence of representing decimal fractions in binary IEEE 754 double-precision floating point, the format every mainstream CPU implements and that Python’sfloatwraps verbatim. The trap is that the symptoms (failed equality tests, money totals off by a cent,nansilently poisoning amin(),round()“wrongly” returning2.67) appear far from the cause, so they get misdiagnosed as logic errors. This note is the field guide to those symptoms and their fixes. The representation itself — how adoublelays out sign, exponent, and 52-bit mantissa, and how CPython boxes it — is covered in CPython Float Internals; here we assume that machinery and focus on what goes wrong and how to defend against it.
Mental Model: A Float Is a Nearby Binary Fraction, Not Your Decimal
The single idea that explains every pitfall below: a float does not store the number you wrote. It stores the nearest value representable as ±(1 + f) × 2^e where f is a 52-bit binary fraction. Many decimal numbers that look perfectly ordinary — 0.1, 0.2, 0.3, 1.10 — have no exact binary representation, the same way 1/3 has no finite decimal representation. The literal 0.1 in your source becomes the closest double, which is actually
0.1000000000000000055511151231257827021181583404541015625
(verified live on 3.14.5: Decimal(0.1) prints exactly that). When you later compare, sum, or round these approximations, the tiny errors interact and surface.
flowchart TD A["You write 0.1 in source"] --> B["Parser asks: nearest<br/>IEEE 754 double to 0.1?"] B --> C["Stored: 0.1000000000000000055511..."] A2["You write 0.2"] --> B2["nearest double to 0.2"] B2 --> C2["Stored: 0.2000000000000000111022..."] C --> D["Add the two doubles"] C2 --> D D --> E["Result rounds to nearest double:<br/>0.30000000000000004"] F["You write 0.3"] --> G["nearest double to 0.3:<br/>0.299999999999999988897..."] E --> H{"== ?"} G --> H H --> I["FALSE — two different doubles"] style I fill:#fdd
Figure: why 0.1 + 0.2 != 0.3. The two operands are stored as approximations; their exact sum rounds to a double (0.30000000000000004) that differs in the last bit from the double chosen for the literal 0.3. The insight: equality compares the stored approximations, which were rounded at different points, so they need not coincide even when the mathematical values are equal.
We can see this directly with the lossless float.hex() view (verified on 3.14.5):
>>> (0.1).hex() # '0x1.999999999999ap-4' <- mantissa ends in ...a
>>> (0.2).hex() # '0x1.999999999999ap-3'
>>> (0.3).hex() # '0x1.3333333333333p-2' <- ends in ...3
>>> (0.1 + 0.2).hex() # '0x1.3333333333334p-2' <- ends in ...4, off by one ULPThe sum’s hex differs from 0.3’s hex in the final hex digit — one unit in the last place (ULP). That one-bit gap is the entire bug.
The Canonical Surprise and Why repr “Looks Wrong”
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
FalseTwo things happen here, and conflating them causes confusion. First, the value really is slightly more than 0.3. Second, the displayed string 0.30000000000000004 is the shortest decimal string that round-trips back to that exact double (float tutorial). Since Python 3.1, repr(float) uses David Gay’s shortest-round-trip algorithm: it prints the fewest digits that uniquely identify the stored double. So repr(0.1) is '0.1' (because 0.1 is the shortest string that maps to that double), but repr(0.1 + 0.2) needs all 17 digits because no shorter string identifies that particular sum. On Python 3.14, str(float) and repr(float) are identical — both shortest-round-trip — so you cannot hide the discrepancy by using print() versus the REPL (verified: str(0.1+0.2) == repr(0.1+0.2)). The value is honest; the surprise is that your mental model expected 0.3.
Accumulated rounding error
A single operation loses at most half a ULP, but errors compound over a loop. The classic demonstration (verified live):
>>> total = 0.0
>>> for _ in range(10):
... total += 0.1
>>> total
0.9999999999999999
>>> total == 1.0
FalseCuriously, the built-in sum([0.1] * 10) does equal 1.0 on 3.14.5 — and this is not luck. Per the 3.12 changelog, “sum() now uses Neumaier summation to improve accuracy and commutativity when summing floats or mixed ints and floats” (contributed by Raymond Hettinger). Neumaier summation tracks a running error-correction term and is dramatically more accurate than a naive += loop. So sum([1e16, 1.0, -1e16, 1.0]) returns the exact 2.0 (verified) where a manual loop would lose the 1.0s entirely. This is the cruelest property of float bugs: they are order-dependent and input-dependent, so a hand-rolled loop drifts while sum does not, and sum([0.1]*10) passes while sum([0.1]*7) fails:
>>> sum([0.1] * 7) # 0.7000000000000001
>>> sum([0.1] * 7) == 0.7 # FalseA bigger explicit loop makes the manual-accumulation drift undeniable:
>>> total = 0.0
>>> for _ in range(1_000_000):
... total += 0.1
>>> total # 100000.00000133288, not 100000.0
>>> total - 100000.0 # 1.3328826753422618e-06 driftAnd the money version, which has bitten countless billing systems (verified):
>>> price = 0.0
>>> for _ in range(10):
... price += 1.10
>>> price
10.999999999999998 # ten $1.10 items total $10.999..., not $11.00A receipt that prints $10.999999999999998 or, after naive rounding, charges a cent wrong, is the production face of this note.
Fixes for the Equality and Accuracy Traps
Never use == on floats — use a tolerance
Replace a == b with math.isclose(a, b). Its definition (verified against the docs) is:
abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)with defaults rel_tol=1e-09 and abs_tol=0.0. So math.isclose(0.1 + 0.2, 0.3) returns True. But there is a sharp edge: the default abs_tol=0.0 makes comparisons against zero always fail for any nonzero input. From the docs verbatim: “When comparing x to 0.0, isclose(x, 0) is computed as abs(x) <= rel_tol * abs(x), which is False for any nonzero x”. Verified:
>>> math.isclose(0, 1e-10) # False! no abs_tol
>>> math.isclose(0, 1e-10, abs_tol=1e-9) # TrueSo when one side may be zero, you must pass an abs_tol. The hand-rolled alternative abs(a - b) < tol has exactly this same flaw if you reuse one absolute tol across very different magnitudes — a tolerance of 1e-9 is meaningless when comparing values near 1e12. isclose’s relative component is precisely the fix for scale.
math.fsum() for accurate summation
When you must add many floats, math.fsum(iterable) tracks multiple partial sums and returns a correctly-rounded total — provably the nearest double to the true mathematical sum (docs). The built-in sum (Neumaier-compensated since 3.12) is very good but not guaranteed correctly-rounded for adversarial inputs; fsum is. It is the right tool when you are accumulating values yourself (a manual += loop) and cannot rely on the built-in’s compensation:
>>> total = 0.0
>>> for _ in range(1_000_000):
... total += 0.1
>>> total # 100000.00000133288 (drift)
>>> math.fsum([0.1] * 1_000_000) # 100000.0 (correct)Use fsum (or math.sumprod for dot products) anywhere accumulated error matters — statistics, physics, financial aggregation done in float — and prefer it over a hand-written summation loop. (Both verified on 3.14.5.)
decimal.Decimal for exact decimal arithmetic — the money rule
The real fix for money is to stop using binary float entirely and use decimal.Decimal, which represents numbers in base 10 exactly (decimal docs):
>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2') # Decimal('0.3')
>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3') # TrueThe critical subtlety: construct from a string, not a float. Decimal(0.1) (float argument) faithfully captures the float’s error — Decimal('0.1000000000000000055511151231257827021181583404541015625') — defeating the purpose. Always Decimal('0.1'). Decimal also gives you explicit control over precision and rounding mode (getcontext().rounding), which is mandatory for regulated financial code. The deeper mechanism of Decimal and Fraction lives in The decimal and fractions Modules.
A concrete worked failure shows why this matters end-to-end. Imagine an invoice line summing ten items at $1.10, then applying 8% tax and rounding to cents:
>>> subtotal = 0.0
>>> for _ in range(10):
... subtotal += 1.10
>>> subtotal # 10.999999999999998 (already wrong before tax)
>>> tax = subtotal * 0.08
>>> round(subtotal + tax, 2) # 11.88, but built on a corrupted subtotalThe subtotal is already off by a fraction of a cent before tax, and round(..., 2) cannot recover the lost precision — it can only round the already-wrong value, occasionally tipping a half-cent the wrong way across thousands of invoices into a measurable accounting discrepancy. This is the well-documented class of bug behind the old folklore that early electronic trading and billing systems “leaked” fractions of a cent. The Decimal version is exact at every step:
>>> from decimal import Decimal, ROUND_HALF_UP
>>> subtotal = sum(Decimal('1.10') for _ in range(10)) # Decimal('11.00') exactly
>>> tax = subtotal * Decimal('0.08') # Decimal('0.8800')
>>> (subtotal + tax).quantize(Decimal('0.01'), ROUND_HALF_UP) # Decimal('11.88')Note also that Decimal lets you pick ROUND_HALF_UP explicitly — the human-intuitive “round half up” — rather than float round()’s banker’s rounding (below), which is itself a frequent source of “the total is a cent off” tickets.
fractions.Fraction for exact rationals
When the right model is exact ratios rather than fixed decimal places, fractions.Fraction stores an integer numerator and denominator, so arithmetic is exact (verified):
>>> from fractions import Fraction
>>> Fraction(1, 10) + Fraction(2, 10) # Fraction(3, 10)
>>> Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10) # TrueFraction is the choice for symbolic-exact work (probabilities, ratios, anything where 1/3 + 1/3 + 1/3 must be exactly 1).
Special-Value Traps: nan, inf, and Signed Zero
IEEE 754 defines three families of “not an ordinary number” values, each with a sharp edge.
nan is not equal to itself
>>> nan = float('nan')
>>> nan == nan # False
>>> nan != nan # TrueThis is mandated by IEEE 754 — NaN (Not a Number) compares unequal to everything, including itself (value comparisons). It is occasionally useful (x != x is a one-liner nan test), but it breaks every algorithm that assumes reflexivity of equality.
The most dangerous consequence is nan silently corrupting min, max, and sorted. Because every comparison with nan is False, the result depends on where the nan sits in the data (verified):
>>> min([float('nan'), 1, 2, 3]) # nan (nan is first, nothing beats it)
>>> min([1, 2, float('nan'), 3]) # 1 (nan is mid, never replaces the min)
>>> sorted([3, float('nan'), 1, 2]) # [3, nan, 1, 2] — NOT sorted!sorted returns a garbage order when a nan is present, with no error, because the comparison function violates the total-order contract sorted relies on. This is a stealth bug: your data looks sorted until a nan slips in. Diagnose by checking for nan before sorting — [x for x in data if not math.isnan(x)] or math.nan detection — and decide explicitly whether to drop, raise, or sort nans last (sorted(data, key=lambda x: (math.isnan(x), x))).
A second nan trap: membership uses identity before equality. In a set or dict, nan in {nan} is True for the same object (CPython short-circuits on is before ==), but a fresh nan is not found (verified):
>>> n = float('nan')
>>> n in {n} # True (identity match)
>>> float('nan') in {n} # False (different object, == is False)So you can store a nan key and retrieve it with the exact same object, but never with an equal-looking one.
inf arithmetic produces nan
>>> inf = float('inf')
>>> inf - inf # nan
>>> inf * 0 # nan
>>> inf / inf # nan
>>> inf + inf # infInfinity arithmetic follows IEEE rules: indeterminate forms (inf - inf, inf / inf, inf * 0) yield nan, which then poisons everything downstream per the nan rules above. The subtlety is how you get an inf in the first place. Plain arithmetic that overflows returns inf silently — 1e308 * 10 gives inf, and float('1e400') gives inf (both verified). But many math-module functions instead raise OverflowError rather than returning inf: math.exp(1000) raises “math range error”, not inf (verified). So whether an overflow is loud (an exception) or silent (an inf that later mutates into a nan far from the overflow site) depends on which operation overflowed — a real diagnosis headache.
Signed zero
IEEE 754 has both +0.0 and -0.0. They compare equal but are distinct values (verified):
>>> 0.0 == -0.0 # True
>>> repr(-0.0) # '-0.0'
>>> math.copysign(1, -0.0) # -1.0 (the sign is preserved!)-0.0 is invisible to == but visible to copysign, to atan2, and to functions whose result sign depends on the sign of a zero input. (Note Python does not let -0.0 resurface as a signed infinity through division: 1 / -0.0 raises ZeroDivisionError, verified — unlike C or NumPy, which return -inf. Division by any float zero, signed or not, raises.) Code that branches on the sign of a result via math.copysign or math.atan2 can still take the wrong branch when a computation underflows to -0.0.
The round() Surprise: Banker’s Rounding and Float Representation
round() trips people two independent ways, and they often blame the wrong one.
First, banker’s rounding (round-half-to-even). Verified on 3.14.5:
>>> round(0.5) # 0 (not 1!)
>>> round(1.5) # 2
>>> round(2.5) # 2 (not 3!)
>>> round(3.5) # 4Per the docs: “if two multiples are equally close, rounding is done toward the even choice.” So exact halves round to the nearest even integer. This is deliberate — round-half-to-even avoids the systematic upward bias of always-round-half-up over large datasets — but it astonishes anyone taught grade-school “round half up.” If you need half-up, use decimal.Decimal with ROUND_HALF_UP.
Second, and sneakier, the representation interacts with rounding. The famous case from the docs verbatim: “round(2.675, 2) gives 2.67 instead of the expected 2.68. This is not a bug.” Verified:
>>> round(2.675, 2) # 2.67Here banker’s rounding is not the cause — 2.675 is not actually stored as 2.675. The nearest double to 2.675 is slightly below 2.675, so it rounds down to 2.67. The two effects (banker’s rounding on exact halves, and the literal not being an exact half) are easy to conflate; diagnose by inspecting Decimal(2.675) to see the real stored value. For correct decimal rounding of money, round with Decimal, never with float round().
Failure Modes and Diagnosis Checklist
- A test passes but production fails. Float errors are data- and order-dependent (
sum([0.1]*10) == 1.0but a loop != 1.0). Never trust a single hand-picked example. Test withisclose. - An equality
if a == b:never fires (or fires unpredictably). Almost always float==. Switch tomath.isclosewith anabs_tolif zero is in play. - A total is off by a cent. Binary float for money. Move to
Decimalconstructed from strings. min/max/sortedreturns nonsense. Ananis in the data; comparisons silently misbehave. Filter or key-sortnans explicitly.- A value becomes
nan“out of nowhere.” Trace back to aninf - inf,inf * 0,0/0, ormathdomain edge;nanthen propagates. - A
Decimalstill shows float noise. You wroteDecimal(0.1)(float) instead ofDecimal('0.1')(string).
Uncertain (2026-06-01)
Verify: the exact least-significant-bit value of a given
math.fsumresult on non-Linux / non-glibc builds. Reason: this is platform-dependent by design — the 3.14fsumdocs themselves warn (text confirmed verbatim against the rendered 3.14 docs this session) that “on some non-Windows builds, the underlying C library uses extended precision addition and may occasionally double-round an intermediate sum causing it to be off in its least significant bit.” So no single run resolves it for all platforms — it is irreducibly build-specific and stays flagged. The built-insum’s accuracy is not uncertain: it has used Neumaier summation since 3.12 (confirmed in What’s New 3.12: “sum()now uses Neumaier summation to improve accuracy”). The direction of the lesson (preferfsum/Decimalover a naive loop) holds on every platform; only the final ULP can shift. Verified on CPython 3.14.5 / GCC 12.3.0 Linux. uncertain
See Also
- CPython Float Internals — the
PyFloatObjectbox, the IEEE 754 double layout, thedtoa-based shortest-round-triprepr, andfloat.hex. Read this for why the representation behaves as it does. - The decimal and fractions Modules — the exact-arithmetic escape hatches (
Decimalbase-10,Fractionrationals) and their context/precision controls. - Truthiness and Falsy Values —
0.0,-0.0, andfloat('nan')truthiness (nanis truthy,0.0is falsy). - Python Internals MOC §16 “Common Gotchas”.