Module 1 · How Python really works

Everything is an object

Intermediate 18 min read The model everything else stands on

In Python, every value is an object: numbers, strings, functions, classes, modules, even None. And a variable is not a box that holds a value. It is a name attached to an object. Once this model is in your head, a long list of "weird" behaviours stops being weird.

🏷️

Luggage tags, not boxes

Picture objects as suitcases on a carousel and names as luggage tags tied to them. a = [1, 2] ties the tag a to a suitcase. b = a ties a second tag to the same suitcase; it does not pack a new one. Open the suitcase through either tag and you see the same contents. a = [9] moves the tag a to a different suitcase and leaves the first one, and tag b, alone.

1. Every object has identity, type and value

x = [1, 2, 3]
print(id(x))          # identity: unique while the object is alive (in CPython, its memory address)
print(type(x))        # type: fixed for the object's lifetime, decides what operations mean
print(x)              # value: the contents, which may change if the type is mutable

print(type(42), type("hi"), type(None), type(print), type(int))
1297536905472 <class 'list'> [1, 2, 3] <class 'int'> <class 'str'> <class 'NoneType'> <class 'builtin_function_or_method'> <class 'type'>

2. Assignment binds a name. It never copies.

① a = [1, 2] b = a a b list [1, 2] One object, two names. b.append(3) is visible through a. ② a = [9] (rebinding, not mutation) a b list [9] (new) list [1, 2] (untouched) The tag a moved. Nothing happened to the old list. Rule: name = … rebinds a name. obj.method(), obj[i] = … and obj.attr = … change an object.
a = [1, 2]
b = a              # a second name for the SAME list
b.append(3)        # mutate the object
print(a, b, a is b)

a = [9]            # rebind the name a; the list is not touched
print(a, b, a is b)
[1, 2, 3] [1, 2, 3] True [9] [1, 2, 3] False

3. is versus ==

== asks "do these objects have equal values?" and calls __eq__, so each type decides what equal means. is asks "are these the same object?" and just compares identities. It cannot be overridden.

a = [1, 2, 3]
b = [1, 2, 3]
print(a == b, a is b)     # equal values, two different objects

c = a
print(a == c, a is c)     # same object, so trivially equal too

x = None
print(x is None)          # the right way: None is a singleton
True False True True True
Use is only for singletons

Compare with is only against None, True, False, or a sentinel object you created yourself. x is 1000 may appear to work in a quick test because CPython caches some objects (small integers, some strings; lesson 03), but that is an implementation detail. Python even warns about it at compile time: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?

4. Mutable and immutable types

Immutable (value can never change)Mutable (value can change in place)
int, float, complex, boollist
str, bytesdict
tuple, frozensetset, bytearray
None, functions' code objects, rangesmost instances of your own classes

"Changing" an immutable object really creates a new one and rebinds the name. id() makes that visible:

s = "cat"
before = id(s)
s += "s"                      # builds a NEW string, rebinds s
print(s, id(s) == before)

nums = [1, 2]
before = id(nums)
nums += [3]                   # list += extends the SAME list in place
print(nums, id(nums) == before)
cats False [1, 2, 3] True
Why this matters

+= means "mutate in place if the type supports it (__iadd__), otherwise build a new object and rebind". Same syntax, two behaviours, decided by the type. That is why the two examples above behave differently.

5. Function arguments: "call by sharing"

Python passes references to objects. The parameter becomes a new name bound to the caller's object. A function can therefore mutate an object you pass in, but rebinding its parameter never affects the caller's name.

def add_item(basket):
    basket.append("apple")      # mutates the caller's list

def replace(basket):
    basket = ["pear"]           # rebinds the local name only

groceries = []
add_item(groceries)
replace(groceries)
print(groceries)
['apple']

This is neither "pass by value" (the list was not copied) nor "pass by reference" in the C++ sense (the caller's variable could not be reassigned). The result shows exactly one apple and no pear.

6. Copies: shallow versus deep

copy.copy(grid) — shallow grid [•, •] shallow [•, •] inner [1, 2] inner [3, 4] New outer list, SHARED inner lists. shallow[0].append(9) also changes grid. copy.deepcopy(grid) — deep grid [•, •] deep [•, •] [1, 2] [3, 4] [1, 2] copy [3, 4] copy Everything reachable is copied. Fully independent. list(x), x.copy(), x[:], dict(x) and {**x} are all SHALLOW copies.
import copy

grid = [[1, 2], [3, 4]]
shallow = copy.copy(grid)        # same as grid.copy() or grid[:]
deep = copy.deepcopy(grid)

shallow[0].append(9)             # mutate an inner list through the shallow copy
print("grid:   ", grid)          # affected: the inner list is shared
print("deep:   ", deep)          # unaffected
print(shallow is grid, shallow[0] is grid[0], deep[0] is grid[0])
grid: [[1, 2, 9], [3, 4]] deep: [[1, 2], [3, 4]] False True False

7. Three classic traps, explained by one model

The multiplied grid

board = [[0] * 3] * 3            # ONE row object, referenced three times
board[0][0] = "X"
print(board)

board = [[0] * 3 for _ in range(3)]   # a NEW row each time round the loop
board[0][0] = "X"
print(board)
[['X', 0, 0], ['X', 0, 0], ['X', 0, 0]] [['X', 0, 0], [0, 0, 0], [0, 0, 0]]

[row] * 3 copies references, not rows. The comprehension evaluates [0] * 3 three times and so builds three separate lists.

The tuple that both fails and succeeds

t = ([1, 2], "x")
try:
    t[0] += [3]
except TypeError as e:
    print("TypeError:", e)
print(t)                          # ...and yet the list inside DID change
TypeError: 'tuple' object does not support item assignment ([1, 2, 3], 'x')

t[0] += [3] runs in two steps. First t[0].__iadd__([3]) extends the list in place, which succeeds. Then Python assigns the result back with t[0] = …, which fails because tuples are immutable. The mutation happened before the error.

The default argument that remembers

def add_tag(tag, tags=[]):        # the default list is created ONCE, when def runs
    tags.append(tag)
    return tags

print(add_tag("a"))
print(add_tag("b"))               # same list object as last time
print(add_tag.__defaults__)       # it lives on the function object
['a'] ['a', 'b'] (['a', 'b'],)

Lesson 04 covers the standard fix (tags=None) and why Python works this way.

8. Everything really is an object

import math

def greet(name):
    return f"hi {name}"

greet.author = "me"                    # functions have attributes
say = greet                            # and can be bound to other names
print(say("Asha"), say.__name__, say.author)

print(type(greet), type(math), type(int), type(type))
print(isinstance(3, object), isinstance(greet, object), isinstance(int, object))
hi Asha greet me <class 'function'> <class 'module'> <class 'type'> <class 'type'> True True True

This uniformity is what makes decorators (lesson 05), functions stored in dicts, and classes passed as arguments possible. There is no special category of "things that are not objects".

Recap

  • Every value is an object with an identity, a type and a value.
  • Names are tags bound to objects. Assignment binds; it never copies.
  • == compares values, is compares identity. Use is for None and sentinels only.
  • Mutability belongs to the type. += mutates lists but rebinds strings and ints.
  • Arguments are passed by sharing: a function can mutate your object but cannot rebind your name.
  • Shallow copies share inner objects; copy.deepcopy copies everything reachable.

Checkpoint

1 · After a = [1]; b = a; a = a + [2], what is b?
a + [2] builds a new list, and a = … rebinds a to it. b still points at the original [1]. With a += [2] instead, the list would be extended in place and b would show [1, 2].
2 · config = {"db": {"host": "a"}}, then c2 = dict(config); c2["db"]["host"] = "b". What is config["db"]["host"]?
dict(config) creates a new outer dict whose values are the same objects. Mutating the nested dict through either one is visible through both. Use copy.deepcopy(config) when nested data must be independent.
3 · Why is if value is None: preferred over if value == None:?
There is exactly one None object. is checks identity directly and cannot be overridden, while == calls __eq__, which a class (NumPy arrays, for example) may define in surprising ways.