Skip to content
aviral gupta

// A4.4 · ~35 min · Advanced

Floating point, decimal and fractions

After this lesson you can explain why 0.1 + 0.2 != 0.3, compare floats with math.isclose, compute money exactly with Decimal from strings and quantize, and use Fraction for exact ratios.

Lesson 4 of 5 in A4 Performance and numbers

You will be able to

  • Explain representation error and compare floats with math.isclose, including near zero
  • Compute exactly with decimal.Decimal from strings, round with quantize and set precision in a context
  • Use fractions.Fraction for exact rational arithmetic, and choose between float, Decimal and Fraction
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B4.4 (sets): which of these can be an element of a set?

  2. Predict · Activity 2 of 7

    Predict before you read on: what does this print?

    print(0.1 + 0.2 == 0.3, 0.1 + 0.2)
  3. Practice · Activity 3 of 7

    leftover is 5.551115123125783e-17, rounding noise. Fill in the keyword so that the check treats it as zero.

    result = math.isclose(leftover, 0.0, ____=1e-12)
    result = math.isclose(leftover, 0.0, =1e-12)
  4. Practice · Activity 4 of 7

    What does this print?

    from decimal import Decimal
    
    print(Decimal("0.1") * 3 == Decimal("0.3"), Decimal(0.1) * 3 == Decimal("0.3"))
  5. Practice · Activity 5 of 7

    Match each expression to its result.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. What does this print?

    print(round(2.675, 2), round(0.5), round(1.5), round(2.5))
  7. Apply · Activity 7 of 7

    Mini-task. Write split(total, people): total is a string such as "10.00", and the result is a list of Decimal shares in cents that add up to exactly the total. Round each share down to cents with quantize, then give the leftover cents, one each, to the first people. split("10.00", 3) gives 3.34, 3.33 and 3.33. Check the sum with ==, not with isclose.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

One invoice, three number types

Three lines of an invoice are added up three ways. With floats, the net total is already slightly off. With Decimal built from strings, it is exact, and the 19 % VAT is rounded to cents once, on purpose; the two rounding modes give different cents for an exact half. Finally the total is split three ways with Fraction, which keeps the third exact until you choose to round it.

main.py

import math
from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal
from fractions import Fraction

CENT = Decimal("0.01")
VAT_RATE = Decimal("0.19")
lines = [("sugar", "0.10", 2), ("milk", "0.70", 1), ("coffee", "2.20", 3)]

# 1. Floats: every price is already a little off, and the errors add up.
net_float = sum(float(price) * quantity for _, price, quantity in lines)
print("float net:", net_float)
print("== 7.5:", net_float == 7.5, "| isclose:", math.isclose(net_float, 7.5))

# 2. Decimal from strings: exact, and rounded once, on purpose.
net = sum((Decimal(price) * quantity for _, price, quantity in lines), Decimal("0"))
vat = net * VAT_RATE
print("net:", net, "| exact VAT:", vat)
print("VAT half up:  ", vat.quantize(CENT, rounding=ROUND_HALF_UP))
print("VAT half even:", vat.quantize(CENT, rounding=ROUND_HALF_EVEN))
total = net + vat.quantize(CENT, rounding=ROUND_HALF_UP)
print("total:", total)

# 3. Fraction: an exact third, where Decimal and float must round.
share = Fraction(total) / 3
print("a third:", share, "~", round(float(share), 4))

Run it with

python main.py

Output

float net: 7.500000000000001
== 7.5: False | isclose: True
net: 7.50 | exact VAT: 1.4250
VAT half up:   1.43
VAT half even: 1.42
total: 8.93
a third: 893/300 ~ 2.9767
  • The float net is off in the 16th digit, enough to make == fail; isclose still accepts it.
  • The Decimal net keeps two places, 7.50, because its inputs had two.
  • 1.4250 is an exact half between 1.42 and 1.43: HALF_UP goes up, HALF_EVEN goes to the even 2.
  • Fraction(total) converts the Decimal exactly, and 893/300 stays exact until round.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

An exact invoice total

invoice_total(lines, vat_rate) gets (price, quantity) pairs with prices as strings, and a rate such as "0.19". It must return net plus VAT as a Decimal, with the VAT rounded half up to cents, so 7.50 net at 19 % gives 8.93. The starter uses floats and round. Rewrite it with Decimal built from the strings and quantize.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Decimal(price) * quantity for each line; start sum at Decimal("0") so the result stays a Decimal.

  2. Hint 2

    VAT is (net * Decimal(vat_rate)).quantize(CENT, rounding=ROUND_HALF_UP).

  3. Hint 3

    Return net + vat. Never pass a float to Decimal here: its error would come along exactly.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from decimal import ROUND_HALF_UP, Decimal

CENT = Decimal("0.01")


def invoice_total(lines: list[tuple[str, int]], vat_rate: str) -> Decimal:
    """Net total of (price, quantity) lines plus VAT, the VAT rounded half up to cents."""
    net = sum((Decimal(price) * quantity for price, quantity in lines), Decimal("0"))
    vat = (net * Decimal(vat_rate)).quantize(CENT, rounding=ROUND_HALF_UP)
    return net + vat
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from decimal import ROUND_HALF_UP, Decimal

CENT = Decimal("0.01")


def invoice_total(lines: list[tuple[str, int]], vat_rate: str) -> Decimal:
    """Net total of (price, quantity) lines plus VAT, the VAT rounded half up to cents."""
    net = sum(float(price) * quantity for price, quantity in lines)
    return Decimal(round(net * (1 + float(vat_rate)), 2))

test_main.py

from decimal import Decimal

from main import invoice_total


def test_half_up():
    """7.50 net at 19 % gives VAT 1.4250, rounded up to 1.43: 8.93"""
    got = invoice_total([("0.10", 2), ("0.70", 1), ("2.20", 3)], "0.19")
    assert got == Decimal("8.93"), f"got {got!r}, expected Decimal('8.93')"


def test_returns_decimal_cents():
    """The result is a Decimal with two places"""
    got = invoice_total([("19.99", 3)], "0.07")
    assert isinstance(got, Decimal), f"got a {type(got).__name__}, expected a Decimal"
    assert str(got) == "64.17", f"got {str(got)!r}, expected '64.17' (59.97 + 4.20)"


def test_empty_invoice():
    """No lines: 0.00"""
    got = invoice_total([], "0.19")
    assert str(got) == "0.00", f"got {str(got)!r}, expected '0.00'"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

Readings that match

readings_match(a, b) should say whether two float readings are equal up to floating-point error. The starter uses ==, so 0.1 + 0.2 and 0.3 do not match. Use math.isclose with its default relative tolerance and abs_tol=1e-12, so that tiny leftovers near zero match 0.0, while 1e-6 against 0.0 still does not.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    math.isclose(a, b) already handles 0.1 + 0.2 against 0.3 with its default rel_tol of 1e-09.

  2. Hint 2

    Near zero a relative tolerance is useless: rel_tol * abs(0.0) is 0.0. That is what abs_tol is for.

  3. Hint 3

    return math.isclose(a, b, abs_tol=1e-12)

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import math


def readings_match(a: float, b: float) -> bool:
    """True when two sensor readings are equal up to floating-point error."""
    return math.isclose(a, b, abs_tol=1e-12)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import math


def readings_match(a: float, b: float) -> bool:
    """True when two sensor readings are equal up to floating-point error."""
    return a == b

test_main.py

from main import readings_match


def test_representation_error():
    """0.1 + 0.2 matches 0.3"""
    assert readings_match(0.1 + 0.2, 0.3), "0.1 + 0.2 and 0.3 differ only by representation error"


def test_near_zero():
    """The leftover of 0.1 + 0.2 - 0.3 matches 0.0"""
    leftover = 0.1 + 0.2 - 0.3
    assert readings_match(leftover, 0.0), f"{leftover!r} should match 0.0: near zero, only abs_tol helps"


def test_real_difference():
    """1.0 and 1.0001 do not match"""
    assert not readings_match(1.0, 1.0001), "1.0 and 1.0001 differ by far more than rounding error"


def test_small_but_real():
    """1e-6 does not match 0.0"""
    assert not readings_match(1e-6, 0.0), "1e-6 is a real value, not rounding noise: keep abs_tol tiny"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

An exact mean

exact_mean(values) gets numbers as strings, such as "1/3" or "0.25", and must return their mean as an exact Fraction: the mean of three thirds is exactly 1/3, and of 0.1, 0.2 and 0.3 exactly 1/5. The starter goes through float and loses that. Parse each string with Fraction and never convert to float.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Fraction("1/3") and Fraction("0.25") are exact; float(...) is where the error comes in.

  2. Hint 2

    Start the sum at Fraction(0): sum((Fraction(v) for v in values), Fraction(0)).

  3. Hint 3

    A Fraction divided by an int stays a Fraction, so divide by len(values) at the end.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from fractions import Fraction


def exact_mean(values: list[str]) -> Fraction:
    """The exact mean of values such as "1/3" or "0.25"."""
    return sum((Fraction(v) for v in values), Fraction(0)) / len(values)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from fractions import Fraction


def exact_mean(values: list[str]) -> Fraction:
    """The exact mean of values such as "1/3" or "0.25"."""
    return Fraction(sum(float(Fraction(v)) for v in values) / len(values))

test_main.py

from fractions import Fraction

from main import exact_mean


def test_thirds():
    """1/3, 1/3 and 1/3 average to exactly 1/3"""
    got = exact_mean(["1/3", "1/3", "1/3"])
    assert got == Fraction(1, 3), f"got {got!r}, expected Fraction(1, 3)"


def test_decimal_strings():
    """0.1, 0.2 and 0.3 average to exactly 1/5"""
    got = exact_mean(["0.1", "0.2", "0.3"])
    assert got == Fraction(1, 5), f"got {got!r}, expected Fraction(1, 5): parse the strings, not floats"


def test_mixed():
    """1/2 and 0.25 average to 3/8"""
    got = exact_mean(["1/2", "0.25"])
    assert got == Fraction(3, 8), f"got {got!r}, expected Fraction(3, 8)"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Adding a float to a Decimal

from decimal import Decimal

price = Decimal("19.99")
print(price + 0.01)

What Python prints

TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

Why, and the fix

Decimal refuses floats in arithmetic, because the float’s binary error would leak into an exact value. Write the constant as a Decimal too: price + Decimal("0.01"). Adding an int works, because ints are exact.

A decimal comma in the string

from decimal import Decimal

price = Decimal("12,50")

What Python prints

decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]

Why, and the fix

Decimal parses Python’s number syntax, with a decimal point. Convert German input first, for example text.replace(".", "").replace(",", ".") for "1.234,50". Validate user input and catch decimal.InvalidOperation.

Passing a string to quantize

from decimal import Decimal

print(Decimal("1.005").quantize("0.01"))

What Python prints

TypeError: conversion from str to Decimal is not supported

Why, and the fix

quantize takes the exponent as a Decimal: amount.quantize(Decimal("0.01")). Define it once as a constant, such as CENT = Decimal("0.01"), and pass rounding= explicitly so the rounding rule is visible in the code.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Binary floats approximate

A float is a binary fraction with 53 significant bits. Most decimal fractions, 0.1 among them, have no finite binary form, so Python stores the nearest one and prints the shortest decimal that maps back to it. Every operation can add a new rounding error: 0.1 + 0.2 is 0.30000000000000004. That is how the hardware works, not a bug. So never compare computed floats with ==. math.isclose(a, b) allows a relative difference of 1e-09; near zero, add abs_tol, because rel_tol × 0.0 is 0.0.

Decimal for money

decimal.Decimal("0.10") is exactly ten cents. Build it from a string: Decimal(0.1) copies the float’s error exactly, all 55 digits of it. Decimal keeps significant zeros, so 1.30 + 1.20 is 2.50. Round on purpose with quantize: amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP). The default context rounds ROUND_HALF_EVEN with 28 significant digits; with localcontext(prec=6) you change that for one block only. Arithmetic that mixes Decimal and float raises TypeError, which keeps the error out; a comparison does not raise: Decimal("0.1") == 0.1 is False.

Fraction for exact ratios

fractions.Fraction keeps a numerator and a denominator, always in lowest terms, so Fraction(1, 3) * 3 is exactly 1, where Decimal would have to round 1/3. Build it from integers or a string: Fraction("0.1") is 1/10, while Fraction(0.1) is the float’s exact value, 3602879701896397/36028797018963968. Choose float for measurements and speed, Decimal for decimal money and fixed places, Fraction for exact ratios, and convert only at the edges.

Sources

Last reviewed September 29, 2026