Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Bits & Machine Numbers  ›  Lesson

Why 0.1 + 0.2 Isn't 0.3

Floating Point 28 minA float is a word with sign, exponent and mantissa fields, and most decimals don't fit in it exactly
You're building a piece ofQuarry — the record encoder
This piece — float_fields(): Splits a stored double into sign, exponent and mantissa, to explain a sum that came out wrong.
Scenario A report says 0.1 + 0.2 doesn't match a stored 0.3. Quarry's float inspector opens both numbers up and shows the mantissas one unit apart, so the user can see it's rounding, not corrupt data.
Your task
Build float_fields(x). Return [sign, exponent, mantissa]: the raw 1-bit, 11-bit (biased) and 52-bit fields of the double x. Example: float_fields(0.1) → [0, 1019, 2702159776422298].

Why 0.1 + 0.2 Isn't 0.3

In short: a float is 64 stored bits — sign, exponent and mantissa — and most decimal fractions have no exact binary form, the same way 1/3 has no exact decimal form. Python stores the double closest to the number you typed, so 0.1 is already a tiny bit off before any arithmetic happens, and each later + can round again. Never compare computed floats with ==; use math.isclose (with abs_tol when either side might be 0), and never store money in a float.

0.1 split into its three stored fields: a thin sign box, a wider exponent box, and a long mantissa box, each labelled with its bit width and raw value

A Quarry user runs a report that sums two price columns and checks the answer against a stored total. The check fails for a row where the sum is 0.1 + 0.2 and the total is 0.3. There's nothing wrong with Python. The numbers were never 0.1, 0.2 and 0.3 in the first place. A float is 64 bits laid out in fields, just like Quarry's record header, and most decimal fractions don't fit in them exactly.

This lesson opens those 64 bits with the same shift-and-mask tools from lesson 2. You'll see what's really stored, why sums drift, and what a storage engine should do about it.

python

Why 0.1 has no exact binary form

In decimal, 1/3 never ends: 0.333… A fraction ends in base 10 only if its denominator is made of 2s and 5s. In base 2 the rule is stricter: the denominator must be a power of two. 1/10 has a factor of 5, so in binary it repeats forever.

You can generate the binary digits the way you'd do long division. Double the remainder. If it reaches the denominator, the next bit is 1 and you subtract. Trace the first twelve bits of 1/10:

step through it
1num, den = 1, 10
2bits = []
3for _ in range(12):
4 num *= 2
5 bits.append(num // den)
6 num %= den
7print(bits)

0.0001100110011…, where the block 0011 repeats forever. A float has room for 53 significant bits, so it keeps the first 53 and rounds. The stored 0.1 is the double closest to 0.1, which is slightly above it.

Try the doubling loop yourself. It draws each bit as it's produced. Change the fraction to 1, 4 (a power-of-two denominator), 1, 3 or 3, 5, and see which ones finish and which settle into a repeating pattern:

your code, drawn · array

Your long division in base 2. A denominator that isn't a power of two never reaches remainder 0, so the bits go on forever.

The 64 bits of a double

IEEE 754 double precision splits a word into three fields, the same idea as Quarry's header:

Field Bits Width Meaning
sign 63 1 0 positive, 1 negative
exponent 62–52 11 stored with a bias of 1023, so 1023 means 2⁰
mantissa 51–0 52 the fraction after an implied leading 1.

A normal double is (-1)^sign × 1.mantissa × 2^(exponent − 1023). The implied leading 1 is free, so 52 stored bits give 53 bits of precision. Step through 0.1, whose 64 bits are drawn here as four rows of 16:

watch it move · grid

0.1 as a double. The pattern that should repeat forever is cut off after 52 bits and rounded, and that last rounding is where the error comes from.

The sign bit is 0, so the number is positive. 4 by 16 table. 64 of 64 cells filled.

1 / 5

You can read the fields out of any float with struct. It turns the double into 8 bytes, the bytes into an int, and then it's shift-and-mask as usual:

python

The last two lines are the whole story. 0.3 and 0.1 + 0.2 have the same sign and exponent, and their mantissas differ by exactly one in the last place. Each input was rounded up a little. The exact sum of the two rounded values lies precisely halfway between two doubles, and round-to-nearest-even sends it to the upper one. That's 0.3 plus one unit in the last place (ULP).

What does this print?
print(0.1 + 0.2 == 0.3, round(0.1 + 0.2, 10) == 0.3)

Gaps between doubles grow with size

53 bits of precision means the gap between neighbouring doubles is relative. Near 1 it's 2⁻⁵², about 2.2e-16. Near a million it's about 1.2e-10. And past 2⁵³ the gap is 2, so doubles can't even represent every integer any more:

What does this print?
print(2.0 ** 53 + 1 == 2.0 ** 53)
python

Quarry stores timestamps. If one is a float counting seconds since 1970, how long does it keep microsecond precision? The gap at t seconds is 2^(floor(log₂ t) − 52), which is exactly what math.ulp measures:

python

The gap passes a microsecond in year 273, so 2243, when the timestamp passes 2³³ seconds. It doubles at every power of two, so it grows in steps rather than smoothly. For Quarry the lesson is to keep time as an integer count of microseconds. A 64-bit integer lasts 290,000 years and never loses a tick.

Errors accumulate

One rounding error is tiny. A long loop of additions piles them up, because each += rounds again:

What does this print?
total = 0.0
for _ in range(10):
    total += 0.1
print(total == 1.0)

Three rules keep Quarry correct:

  1. Never compare computed floats with ==. Use math.isclose(a, b), which compares relative to the size of the numbers. Add abs_tol when either might be 0.
  2. Don't store money in floats. Store integer cents, or use decimal.Decimal, which does exact decimal arithmetic. Quarry's price column is an integer.
  3. Sum carefully. math.fsum returns the correctly rounded sum of the exact inputs, and sum() compensates in 3.12+. A plain loop does neither.

This reconciliation check fails on data that's actually fine:

broken — fix it

It prints False True. The first check compares two doubles that differ by one ULP. Make the comparison tolerant of rounding.

Interview corner — is this string a price you can store?

Given prices as strings like "19.99", "5" or "0.3", total them exactly and return the total in cents. Converting each one to float and multiplying by 100 invites errors like 0.29 * 100 = 28.999999999999996. Parse the digits as integers instead:

python

Quick practice

What does this print?
print(0.1 + 0.1 == 0.2)
broken — fix it

It prints True False. 0.3 - 0.1 - 0.2 isn't exactly 0 (off by about -2.78e-17), and isclose's default tolerance is relative, so near zero it's an almost-zero tolerance too.


🎯 Your turn

Write float_fields(x) returning [sign, exponent, mantissa], the three raw bit fields of the double x. The exponent is the 11-bit field as stored (biased), and the mantissa is the 52-bit field.

  • float_fields(0.1) → [0, 1019, 2702159776422298]
  • float_fields(-2.0) → [1, 1024, 0]

Hint — int.from_bytes(struct.pack(">d", x), "big") gives the 64 bits as an integer. Then shift and mask: the sign is >> 63, the exponent is >> 52 masked with 0x7FF, and the mantissa is masked with (1 << 52) - 1.

Then press ▶ Run, try the Live App chips, and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upClose enough to count as equal

Write close_enough(a, b) returning True when two computed floats should count as equal: within a relative tolerance of 1e-9, or within an absolute tolerance of 1e-12 (for values near zero). Otherwise return False.

numerical-errorieee-754
DrillAll 64 bits of a double

Write float_bits(x) returning the 64 bits of the double x as a string of '0' and '1', sign bit first. float_bits(1.0) starts with '0011111111110000' and ends in 48 zeros.

float-bitsieee-754
BuildHow many doubles apart?

For positive finite doubles, the bit patterns sort in the same order as the values, and consecutive doubles have consecutive patterns. Write ulp_distance(a, b) returning how many representable doubles you step through to get from a to b: 0 if they're equal, and always non-negative.

float-bitsnumerical-error
BossThe doubles either side

Write neighbours(x) returning [below, above]: the largest double strictly less than the finite double x, and the smallest double strictly greater. Don't use math.nextafter. Work on the bit pattern of abs(x). Moving away from zero adds 1 to that pattern, and moving toward zero subtracts 1. Zero is special, because its neighbours are the smallest subnormals -5e-324 and 5e-324. The answer is a list, so it's checked exactly, one ULP at a time.

float-bitsbit-manipulation
CapstoneBuild a double from its fields

Write decode_double(sign, exponent, mantissa), the inverse of float_fields, using arithmetic rather than struct. For a normal number (exponent 1…2046) the value is (-1)**sign × (1 + mantissa / 2**52) × 2**(exponent - 1023). For exponent 0 the number is subnormal: there's no implied 1, and the value is (-1)**sign × mantissa × 2**-1074. Return the result as value.hex(), which spells out every bit so the check is exact. Exponent 2047 means infinity or NaN, so return None.

ieee-754bit-manipulation
ExplainShould Quarry store prices as floats?

A Quarry user stores prices as float and reports that a reconciliation check, sum(prices) == stored_total, sometimes fails even though every price is correct. Explain what's happening at the level of bits, and recommend how Quarry should store and compare money.

float_fields.py