Data and control flow
Numbers, Strings, and Why the Types Matter
Division and modulo behave in ways that surprise people, floats lose cents, and strings do not change. Here is what actually happens.
A script that adds up prices and prints a total looks like the least interesting program anybody can write. It is also where most first bugs live, because the arithmetic in Python does not behave the way most people assume. Division hands you a float when you expected a whole number. Rounding turns 2.675 into 2.67. A string cannot be edited in place, so the obvious way to build one in a loop is the slow way.
None of these are defects. They follow from a small set of rules that are short enough to hold in your head. Once you have them, the type of a value in a traceback stops being noise and starts being the answer to the question you were asking.
Division always produces a float, and integer division rounds down
There are two division operators in Python and they mean different things, not different
precisions of the same thing. / is true division and returns a float even when
both operands are integers and the result is exact. // is floor division and
returns the largest integer less than or equal to the exact quotient.
print(7 / 2)
print(7 // 2)
print(-7 // 2)
print(type(7 / 2).__name__, type(7 // 2).__name__)
3.5
3
-4
float int
That -4 is the line worth pausing on. Floor division does not chop off the
decimal part; it moves down to the next integer, and on a negative number "down" is further
from zero. Truncation would have produced -3. The behavior is deliberate, and
it is what makes the next operator consistent.
Modulo is defined by the sign of the divisor
% in Python is not the remainder from school arithmetic on signed numbers.
It returns a value with the same sign as the divisor, and it satisfies
a == (a // b) * b + a % b for every integer pair. Check it against the examples
above:
print(7 % 3)
print(-7 % 3)
print(7 % -3)
print(-7 % -3)
print(divmod(-7, 3))
1
2
-2
-1
(-3, 2)
divmod returns both results from one computation, and the identity above is
easier to remember than either operator on its own. Where the sign rule earns its keep is
cyclic arithmetic: "three hours before midnight" is (0 - 3) % 24 and gives
21. A language that defines modulo by the dividend gives -3 there,
and you get to write the sign check yourself.
Floats are binary fractions and sometimes lose a cent
A float stores a value as a sign, a binary fraction and an exponent. 0.1 has
no exact binary representation for the same reason that one third has no exact decimal one,
so every literal you write is rounded to the nearest representable value. The error is
microscopic on its own and it compounds when you add values up:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
total = 0.0
for _ in range(10):
total += 0.1
print(total, total == 1.0)
0.30000000000000004
False
0.9999999999999999 False
Rounding is where it stops being a curiosity. round uses banker's rounding
to the nearest even value, so round(2.675, 2) gives 2.67 rather
than 2.68: the float nearest to 2.675 is slightly below it, and there is no
tie to break.
decimal.Decimal stores decimal digits the way you would write them by hand,
which makes it exact for the arithmetic that money uses. It is in the standard library, and
the cost is speed and a little ceremony:
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))
print(round(2.675, 2))
print(Decimal("2.675").quantize(Decimal("0.01")))
0.3
2.67
2.68
The quote marks around "0.1" are load-bearing. Decimal(0.1)
builds a Decimal from the float, so it faithfully reproduces the binary error.
Decimal("0.1") builds it from the digits you typed. Always pass a string, or an
int, never a float.
When a float is the right answer
Audio, graphics, physics and statistics all run on floats, and the error above is
smaller than the noise in the data. Reach for Decimal when a value has to
reconcile against another system: invoices, tax, ledger balances, anything where a cent
going missing is a bug rather than a rounding artefact. Decimal arithmetic is slower, so
converting a numeric workload wholesale is the wrong move.
Five string methods carry most of the weight
Data arrives with padding, inconsistent case and awkward separators, and the standard library already knows how to handle each of those problems.
line = " Python, from First Script "
print(line.strip())
print(line.strip().lower())
print(line.split())
print("a,b,c".split(","))
print("-".join(["2026", "04", "14"]))
print("report.csv".endswith(".csv"))
print("Ana Ferreiro".replace("a", "@"))
Python, from First Script
python, from first script
['Python,', 'from', 'First', 'Script']
['a', 'b', 'c']
2026-04-14
True
An@ Ferreiro
Two of those deserve a closer look. split() with no argument splits on runs
of whitespace and drops empty pieces, so accidental double spaces vanish. Pass a separator
and the rules change: split(",") keeps empty fields. That is what you want when
parsing a CSV row with a missing column, and it is also what turns a trailing comma into a
phantom empty value. replace replaces every occurrence, not the first one, and
every method here returns a new string rather than touching the original.
Note that line.split() above left the comma attached to
"Python,". Punctuation is part of the token until you remove it, which is why a
word count over real prose finds end. and end as two different
words.
f-strings format as well as interpolate
An f-string runs the expression inside the braces and inserts the result. Everything after a colon is a format spec, and the specs are worth learning because they replace manual padding and manual rounding:
print(f"{2 / 3:.3f}")
print(f"{1234567:,}")
print(f"{0.1856:.2%}")
print(f"{'ana':>8}|")
print(f"{'ana':<8}|")
0.667
1,234,567
18.56%
ana|
ana |
The spec reads as [[fill]align][width][,][.precision][type]. .3f
is three digits after the point, , inserts thousands separators,
.2% multiplies by a hundred and appends a percent sign. Alignment is
> for right, < for left and ^ for centred. A
width alone right-aligns numbers and left-aligns strings, which is why a first attempt at
tabular output comes out ragged: the columns need explicit alignment rather than whatever the
type defaults to. The older "%s" % value and
"{}".format(value) forms still work and still appear in code you will read.
Write f-strings in anything new.
Strings are immutable, so building one in a loop is quadratic
You cannot change a string in place. There is no method that edits one, and indexing assignment raises rather than doing something surprising:
name = "ana"
try:
name[0] = "A"
except TypeError as exc:
print(type(exc).__name__, exc)
print(name.upper(), name.replace("a", "o"))
TypeError 'str' object does not support item assignment
ANA ono
Because every method returns a new object, accumulating text with += inside
a loop allocates a new string on every iteration and copies the text so far into it. Add
n pieces of length k and the copying is proportional to
n²k. CPython has an optimisation for the common s += t case where the
left-hand string has no other reference, which hides the cost in the simplest version of the
loop. Any other context removes that escape hatch, and the allocation is visible without a
stopwatch:
import sys
acc = {"text": ""}
for _ in range(5):
before = id(acc["text"])
acc["text"] += "x"
print(acc["text"], before != id(acc["text"]), sys.getsizeof(acc["text"]))
x True 42
xx True 43
xxx True 44
xxxx True 45
xxxxx True 46
Every line reports a different identity, so each iteration built a new string object rather than extending one, and the size tracks the content because the whole text moved into the new object. Keeping the accumulator in a dictionary is deliberate: the extra reference is exactly the condition that disables CPython's in-place optimisation. The same loop with the accumulator as a plain local variable avoids most of the copying because the interpreter can tell nobody else is watching, which is why this pattern looks harmless in a quick test and stops being harmless once the text lives in an object or gets passed to a function.
parts = []
for index in range(5):
parts.append(f"line {index}\n")
document = "".join(parts)
print(document)
line 0
line 1
line 2
line 3
line 4
Collecting the pieces in a list and joining once does one allocation for the final string and amortised constant-time appends for the parts, because a list, unlike a string, is mutable. A log builder that appends a line per request meets the size where this matters. A five-iteration loop does not, and I would not add a list to one for its own sake.
bytes and str are different types and the boundary is explicit
A str is a sequence of Unicode code points. A bytes is a
sequence of integers from 0 to 255. Files, sockets and hashing functions deal in bytes;
almost everything else deals in text. The conversion is not automatic, which is the point:
text = "Ana"
blob = text.encode("utf-8")
print(text, len(text))
print(blob, len(blob))
print(blob.decode("utf-8"))
print(b"\xc3\xa9".decode("utf-8"))
Ana 3
b'Ana' 3
Ana
é
Length counts different things on each side. That last line decodes the two bytes
c3 a9, which are one code point, so a str length is not a byte count
and truncating bytes at a fixed offset can cut a character in half. Open a file in text mode
and you get decoding for free, plus an exception when the bytes are not valid UTF-8. Pass
encoding="utf-8" to open rather than trusting the platform default.
Type errors stop being mysterious once you know the type
Most of the failures from this lesson arrive as one of two exceptions. A
TypeError means an operation received a type it does not accept, and the message
names both types. That is the interpreter telling you which of your values is not what you
assumed, and the useful question is where it came from. A ValueError means the
type was accepted and the contents were not: int("3.10") raises
ValueError: invalid literal for int() with base 10: '3.10', because that string
looks like a number to you and not to int. Convert with
float("3.10") first, or keep the text and hand it to Decimal.
My rule after enough of these: fix the type of a value at the boundary where it enters the program, convert it there, and let format specs handle how it looks on the way out. Doing arithmetic on whatever happened to be in the string is how a totals column ends up four hundred lines away from the bug.
Write invoice.py that prints a receipt for these rows. Each row is
name, unit price, quantity.
coffee, 3.10, 2
tea, 2.35, 1
cake, 4.20, 3
water, , 1
Print one line per row with the name left-aligned in eight columns and the line total right-aligned in eight, two decimal places, then a total line for the sum. The fourth row has no price. Decide what that row should print and say why in a comment.
Then answer two questions with evidence from your own run: does the reported total
change if you accumulate in a float instead of a Decimal, and what does
Decimal("€3.10") raise?
Show the solution and the reasoning
Read the price as a string, keep it as a string, and let Decimal do the
arithmetic. The empty price on the water row is a missing value, not a zero, and the two
need different handling: a zero line total is a fact you know, and a missing price is a
fact you do not.
from decimal import Decimal
rows = [
"coffee, 3.10, 2",
" tea, 2.35, 1",
"cake, 4.20, 3",
"water, , 1",
]
def line_total(row):
name, price, quantity = (part.strip() for part in row.split(","))
if not price:
return name, None
return name, Decimal(price) * int(quantity)
for row in rows:
name, total = line_total(row)
shown = "n/a" if total is None else format(total, ".2f")
print(f"{name:<8}{shown:>8}")
grand_total = sum(
(total for _, total in (line_total(row) for row in rows) if total is not None),
Decimal("0"),
)
print(f"{'total':<8}{grand_total:>8.2f}")
coffee 6.20
tea 2.35
cake 12.60
water n/a
total 21.15
Four decisions are doing the work here. part.strip() on every field
handles the leading spaces on the tea row without a special case. The assignment on the
left-hand side of the generator expression asks for exactly three values, so a row with the
wrong number of fields raises ValueError rather than producing a quietly wrong
total. Decimal(price) * int(quantity) keeps the arithmetic decimal and
integer, because multiplying a Decimal by a float would reintroduce binary error.
format(total, ".2f") is the same operation as the .2f inside an
f-string, and I used both forms so you can see they agree.
The two questions have short answers. A float total here prints 9.65 the
same as the Decimal one, because three two-decimal values that land near a representable
number do not accumulate visible error; run the ten-times 0.1 loop from earlier
and they diverge. The honest conclusion is not that floats are always wrong but that the
error is invisible at this size and unbounded at another, so the type decision comes from
what the value means.
Decimal("€3.10") raises
decimal.InvalidOperation, a subclass of ArithmeticError.
Decimal parses digits, a sign, an exponent and a few special values. Currency
symbols belong in the formatting layer, which is why the receipt prints
6.20 and not €6.20. Adding a symbol is a format spec away if the
currency is fixed, or a lookup table if it is not.
Progress
Mark this lesson done and it stays marked in this browser.