Numbers and Arithmetic That Surprises You
Integers that grow without limit, decimals that cannot represent 0.1 exactly, floor division and modulo, and when money needs a different type altogether.
Integers that grow without limit, decimals that cannot represent 0.1 exactly, floor division and modulo, and when money needs a different type altogether.
Open the interactive prompt and add two numbers that any ten-year-old could handle:
>>> 0.1 + 0.2
0.30000000000000004Nothing is broken. Your Python is not faulty, and this is not a quirk you can configure away — it happens in nearly every programming language, for a reason that is worth ten minutes of your life. By the end of this lesson you will know why, know when it matters, and know what to use instead when you are counting money.
Python has two number types you will meet immediately.
An int is a whole number. Python's have no upper limit,
which is unusual and pleasant — you can keep multiplying and the
answer stays exact:
2 ** 100
# 1267650600228229401496703205376A float is a number with a fractional part. The name is
short for "floating point", which describes how it is stored.
Unlike int, it is approximate, and that is the whole subject
of this lesson.
photo_count = 400 # int
average_size = 3.75 # floatThe moment a float is involved, the answer is a float:
print(10 + 5) # 15 int + int
print(10 + 5.0) # 15.0 int + float
print(type(10 / 2)) # <class 'float'> even though it divides evenlyThat last one surprises people. / always produces a float,
even when the division comes out exact. 10 / 2 is 5.0, not
5.
Here is the reason, and it has nothing to do with computers being unreliable.
Write one third as a decimal. You get 0.333333..., forever. You cannot write it exactly in base ten with a finite number of digits. Nobody finds this alarming; it is a fact about the notation, not about the number.
Computers store numbers in base two, and in base two the number that cannot be written exactly is one tenth. Writing 0.1 in binary gives a repeating pattern that never terminates. So the computer stores the closest value it can fit, which is very slightly off.
you write 0.1
stored as 0.1000000000000000055511151231257827021181583404541015625Add two of those slightly-off values and the error becomes visible:
>>> 0.1 + 0.2 == 0.3
FalseThe lesson is not "floats are broken". It is that a float is an approximation of a decimal number, accurate to about seventeen significant digits, and that comparing two of them for exact equality is asking a question the type cannot answer.
Since exact equality is the wrong question, ask a better one: is the difference small enough not to matter?
Bad — testing two floats for exact equality.
total = 0.1 + 0.2
if total == 0.3:
mark_invoice_paid()Good — asking whether they are close enough.
import math
total = 0.1 + 0.2
if math.isclose(total, 0.3):
mark_invoice_paid()The first version leaves the invoice unpaid, and leaves no trace
of why. There is no error and no warning — the customer paid the
right amount, the comparison said False, and the only symptom
is a support ticket next week. math.isclose asks the question
the values can actually answer, and it takes one line.
Python gives you three division operators because "divide" means three different things depending on what you want back.
print(17 / 5) # 3.4 true division, always a float
print(17 // 5) # 3 floor division, whole part only
print(17 % 5) # 2 modulo, the remainderTrue division. Always a float.
The answer you would write on paper, and the one you want almost all the time.
It gives a float even when the division is exact: 10 / 2 is
5.0, not 5.
Floor division. The whole part.
How many complete fives fit inside seventeen.
Careful with negatives — it rounds down, not toward zero.
Modulo. What is left over.
The remainder after taking out those three complete fives.
Also how you ask "is this divisible by", which comes up more than you would guess.
Together they answer "how many whole ones, and what remains", which comes up far more than you would guess:
total_seconds = 3725
minutes = total_seconds // 60 # 62
seconds = total_seconds % 60 # 5
print(f"{minutes}m {seconds}s") # 62m 5sModulo also answers "is this divisible by", which is how you test for even numbers or do something every tenth time round a loop:
if photo_count % 2 == 0:
print("even")
if index % 100 == 0:
print(f"processed {index}") # a progress line every hundredprint(round(3.7)) # 4
print(round(3.14159, 2)) # 3.14 to two decimal placesNow the part that looks like a bug:
print(round(0.5)) # 0
print(round(1.5)) # 2
print(round(2.5)) # 2round uses banker's rounding: exact halves go to the
nearest even number. It is deliberate. If you always round
halves upward, a large set of numbers drifts upward overall,
because you are adding a consistent bias. Sending halves
alternately up and down cancels the bias out, which matters when
you are summing thousands of rounded values.
If you need the always-up behaviour, be explicit:
import math
print(math.ceil(2.5)) # 3 always up
print(math.floor(2.5)) # 2 always downEverything above leads here. Never store money in a float.
Money has exact decimal values. A price is 19.99, not approximately 19.99.
price = 0.10
total = price * 3
print(total) # 0.30000000000000004
print(f"{total:.2f}") # 0.30 - looks fine, still wrong underneathWhat makes this specific bug expensive is that it hides itself at every stage where you might have caught it.
The arithmetic is slightly off
0.10 * 3 is 0.30000000000000004. A ten-thousandth of a
penny, and nothing warns you.
Formatting hides it on screen
f"{total:.2f}" prints 0.30. Every report, every receipt,
every log line now reassures you that the number is right.
The stored value keeps the error
The database holds the full float, not what you printed. The display and the data no longer agree.
It compounds, then shows up in an audit
Across a hundred thousand transactions the drift becomes a real discrepancy — found months later, by someone who has to trace it back through all of the above.
Use Decimal, which stores decimal digits exactly:
from decimal import Decimal
price = Decimal("0.10") # note: from a STRING
total = price * 3
print(total) # 0.30
print(total == Decimal("0.30")) # TrueThe string is essential. Decimal(0.10) takes the float that is
already slightly wrong and preserves the error faithfully.
Decimal("0.10") reads the digits you wrote.
A useful alternative for simpler cases: store money as whole
numbers of the smallest unit — pence, cents — in an ordinary
int, and divide only when displaying. An int is exact by
definition, so the arithmetic cannot drift.
Numbers arrive from users and files as text, and converting is where they go wrong.
int("42") # 42
float("3.5") # 3.5
int("3.9") # ValueError - int() will not parse a decimal
int(float("3.9")) # 3 - convert twice, on purposeint("3.9") failing surprises people, and the reason is
reasonable: int() on text does not round or truncate, it
parses, and "3.9" is not how a whole number is written.
Conversion fails loudly, which is a gift:
int("four")
# ValueError: invalid literal for int() with base 10: 'four'You will learn to catch that properly in the exceptions lesson. For now, know that bad input raises an error rather than producing a silent zero — which is the behaviour you want.
THE TWO TYPES
int 400 whole, exact, no size limit
float 3.75 fractional, APPROXIMATE, ~17 digits
int + float -> float, always
10 / 2 -> 5.0 true division always gives a float
ARITHMETIC
+ - * as expected
/ true division 17 / 5 -> 3.4
// floor division 17 // 5 -> 3
% remainder 17 % 5 -> 2
** power 2 ** 10 -> 1024
THE PATTERNS THOSE GIVE YOU
n // 60, n % 60 whole part and remainder
n % 2 == 0 is it even
i % 100 == 0 every hundredth time round a loop
ROUNDING
round(x) halves go to the nearest EVEN number
round(x, 2) to two decimal places
math.ceil(x) always up
math.floor(x) always down
int(x) truncates toward zero
COMPARING
0.1 + 0.2 == 0.3 False - do not do this
math.isclose(a, b) ask this instead
MONEY
never use float the error is invisible on screen
Decimal("0.10") from a STRING, never from a float
or store pence as an int, divide only to display
FROM TEXT
int("42") 42
float("3.5") 3.5
int("3.9") ValueError - parses, does not truncate
int(float("3.9")) 3You now know why floats are approximate, when that matters and when it does not, the three kinds of division and what each is for, and the one rule that will save you a genuinely bad day: money does not go in a float. That last one separates people who have shipped a billing bug from people who have not.
Next is Making Decisions with Conditions, where programs stop running straight through and start choosing. It leans on the comparisons introduced here, and adds the thing that makes them useful: doing one thing when the answer is yes and another when it is no.
Before you move on, go to the prompt and add 0.1 to itself ten
times, then compare the result to 1.0. Then do the same with
Decimal("0.1"). Seeing the two answers side by side, in your
own terminal, fixes this permanently in a way that reading about
it does not.