Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 18 30 MIN

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.

Lab 25 minutes

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.