Names, Not Boxes: Python Syntax, Variables and the Built-in Types

7 minute read

Published:

TL;DR: Indentation is syntax, not style. Assignment binds a name to an object; b = a copies the label, never the object, which you can verify with id(). Types belong to objects, not to names, so a name can be rebound to anything. The five types you meet first are int (arbitrary precision), float (binary, and therefore lossy — 0.1 + 0.2 != 0.3), bool (a subclass of int), str and None.

Whitespace is the grammar

Most languages use braces to delimit a block and indentation to hint at it. Python uses indentation for both, so getting it wrong is a syntax error rather than a style complaint:

def classify(n):
    if n < 0:
        return "negative"
    elif n == 0:
        return "zero"
    return "positive"

print(classify(-4))   # -> negative
print(classify(0))    # -> zero

The rules: a colon opens a block, everything in that block is indented by the same amount, and dedenting closes it. Four spaces per level is the convention from PEP 8; never mix tabs and spaces in one file, because Python 3 rejects the mixture outright with TabError. Blank lines and comments are ignored for indentation purposes.

Comments start with # and run to the end of the line. There is no block-comment syntax — a triple-quoted string that is not assigned to anything is sometimes abused as one, but it is really just a string expression that gets built and discarded.

Statements against expressions

An expression produces a value: 2 + 2, len(xs), f(x), [i for i in range(3)]. A statement does something: x = 1, if ...:, return, import os. Every expression can be used as a statement (its value is thrown away), but a statement cannot be used where a value is expected — which is why if x = 5: is a syntax error rather than the silent bug it is in C.

The one deliberate crossover is the walrus operator := (PEP 572, Python 3.8+), an assignment that is an expression:

if (n := len("hello")) > 3:
    print(n)          # -> 5

Variables are names bound to objects

This is the sentence to take literally. x = 42 does not put 42 into a box called x. It creates (or reuses) an integer object and makes the name x refer to it. id() returns an object’s identity — in CPython, its memory address — so you can watch bindings directly:

a = [1, 2, 3]
b = a
print(id(a) == id(b))   # -> True   same object, two names
b.append(4)
print(a)                # -> [1, 2, 3, 4]   'a' sees it too

b = a[:]                # slice makes a new list
print(id(a) == id(b))   # -> False

That second line is the source of an enormous share of beginner bugs: nothing was copied, so mutating through one name is visible through the other. Compare what happens with an integer:

x = 1000
y = x
print(id(x) == id(y))   # -> True
y += 1
print(x, y)             # -> 1000 1001
print(id(x) == id(y))   # -> False

Nothing contradictory is happening. y += 1 cannot modify the integer 1000, because integers are immutable; it computes a new object, 1001, and rebinds y to it. x still labels the original. Lists are mutable, so b.append(4) changes the object itself and every name pointing at it sees the change. Immutable: int, float, bool, str, tuple, frozenset, bytes. Mutable: list, dict, set, and most objects you define yourself.

Dynamic typing

Types attach to objects, not to names, and are checked when an operation runs rather than ahead of time:

n = 42
print(type(n).__name__)   # -> int
n = "now a string"
print(type(n).__name__)   # -> str

This is dynamic typing, and it is quite different from weak typing. Python is strongly typed: it will not quietly coerce across unrelated types, so "3" + 4 raises TypeError: can only concatenate str (not "int") to str rather than producing "34" or 7.

To ask about a type, prefer isinstance(obj, T) over type(obj) is T, because isinstance respects subclasses and accepts a tuple of candidates:

print(isinstance(3, int))              # -> True
print(isinstance(3, (int, float)))     # -> True
print(isinstance(True, int))           # -> True  (!)

The five types you meet first

TypeLiteralMutable?Notes
int42, -7, 1_000_000noarbitrary precision, no overflow
float3.14, 1e-9noIEEE-754 double, 53 bits of mantissa
boolTrue, Falsenosubclass of int; True == 1
str"hi", 'hi'nosequence of Unicode code points
NoneTypeNonenothe single “no value” object

bool really is a subclass of int, which is occasionally useful and occasionally a trap:

print(True + True)          # -> 2
print(sum([True, False, True]))   # -> 2   handy for counting

None is a singleton — there is exactly one of it — so test for it with is, never ==. A function with no return statement returns it implicitly.

Numbers behave in two surprising ways

Integers never overflow. They grow to whatever size is needed, limited only by memory:

print(2 ** 100)             # -> 1267650600228229401496703205376

import math
print(len(str(math.factorial(100))))   # -> 158

Division splits into three operators. / is true division and always returns a float, even when the result is exact. // is floor division, and % is the remainder that matches it:

print(7 / 2)      # -> 3.5
print(7 // 2)     # -> 3
print(7 % 2)      # -> 1
print(7.0 // 2)   # -> 3.0   float in, float out

The important detail is what these do with negatives. Python floors towards negative infinity, unlike C and Java, which truncate towards zero:

print(-7 // 2)          # -> -4     not -3
print(-7 % 2)           # -> 1      not -1
print(divmod(-7, 2))    # -> (-4, 1)

The invariant a == (a // b) * b + (a % b) holds in every case, and the sign of a % b follows the sign of b. This is genuinely convenient: i % n is always a valid index into a length-n sequence, whatever the sign of i.

Floats are binary, so most decimals are inexact. 0.1 cannot be written exactly in base 2, any more than \(1/3\) can be written exactly in base 10. What you get is the nearest representable double:

print(f"{0.1:.20f}")      # -> 0.10000000000000000555
print(0.1 + 0.2)          # -> 0.30000000000000004
print(0.1 + 0.2 == 0.3)   # -> False

There are two fixes, and which you want depends on the problem. For measurement-like quantities, compare with a tolerance:

import math
print(math.isclose(0.1 + 0.2, 0.3))   # -> True

For money and anything else where the decimal digits are the ground truth, use decimal.Decimal — constructed from a string, since Decimal(0.1) would faithfully copy the error you were trying to avoid:

from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))   # -> 0.3
Key Insight — round does not round half up: Python uses banker's rounding (round-half-to-even) as specified by IEEE-754, so round(2.5) is 2 while round(3.5) is 4, and round(-0.5) is 0. This is not a bug; rounding halves consistently upwards introduces a positive bias when you sum many rounded values, and going to the nearest even value cancels it. If you need the school rule, be explicit about it with math.floor(x + 0.5) or a Decimal with an explicit rounding mode.
The classic trap — assignment never copies. b = a gives you a second name for one object, so b.append(4) mutates what a sees. The same applies when you pass a list to a function or store it in two places in a data structure. If you want an independent object, say so: b = a[:] or b = list(a) for a shallow copy, and copy.deepcopy(a) when the elements are themselves mutable. Shallow copies are the subtler hazard: b = a[:] on a list of lists gives a new outer list whose elements are still the same inner lists.

The next post takes these building blocks and combines them: operators and control flow, where == and is part company for good.

Recap

  • Indentation delimits blocks; a colon opens one; four spaces per level, never mixed with tabs.
  • Expressions have values, statements do not; := is the deliberate exception.
  • b = a binds a second name to one object. Mutating through either name is visible through both; rebinding is not.
  • Immutable: int, float, bool, str, tuple, frozenset. Mutable: list, dict, set.
  • / always gives a float; // floors towards negative infinity so -7 // 2 == -4 and -7 % 2 == 1.
  • Integers are arbitrary precision; floats are IEEE-754 doubles, so compare with math.isclose and use Decimal("0.1") for exact decimals.

References

  1. Python Software Foundation. The Python Tutorial: an informal introduction.
  2. Python Software Foundation. Built-in Types.
  3. Python Software Foundation. Floating-Point Arithmetic: Issues and Limitations.
  4. Python Software Foundation. decimal — Decimal fixed-point and floating-point arithmetic.
  5. van Rossum, G., Warsaw, B., & Coghlan, N. PEP 8 — Style Guide for Python Code.
  6. Angelico, C., Peters, T., & van Rossum, G. PEP 572 — Assignment Expressions.
  7. Goldberg, D. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1), 5–48, 1991.