Chapter 2 — Variables Are Names Bound to Objects
1. Opening problem
Predict the output:
first = [1, 2]
second = first
second.append(3)
print(first)
print(second)
print(first is second)
Many developers say:
secondcontains a reference tofirst.
That explanation is close, but still imprecise.
A better model is:
firstandsecondare two names bound to the same list object.
The names do not point to each other. They independently refer to one object.
Understanding this model is essential for:
- function arguments;
- mutation;
- copying;
- default values;
- caching;
- dataclasses;
- equality;
- concurrency;
- memory analysis;
- API design.
2. Names, objects, and bindings
Consider:
score = 10
Python performs conceptually two steps:
- obtain or create an integer object representing
10; - bind the name
scoreto that object.
Then:
score = 20
does not change the integer object 10.
It rebinds the name score to another integer object.
Mental model:
Before reassignment:
score ───> 10
After reassignment:
score ───> 20
The object 10 may continue to exist if another name references it, or it may later become eligible for cleanup.
3. Assignment does not copy
original = {"status": "pending"}
alias = original
No dictionary copy is created.
original ──┐
├──> {"status": "pending"}
alias ─────┘
Therefore:
alias["status"] = "completed"
print(original["status"])
prints:
completed
This behaviour is similar to JavaScript objects:
const original = { status: "pending" };
const alias = original;
alias.status = "completed";
console.log(original.status);
The crucial Python difference is that the same binding model applies consistently to every value, including integers, strings, tuples, functions, classes, and modules.
4. Identity versus equality
Python distinguishes:
- identity: whether two names refer to the exact same object;
- equality: whether two objects represent equivalent values.
Identity
first is second
Equality
first == second
Example:
first = [1, 2, 3]
second = [1, 2, 3]
print(first == second)
print(first is second)
Expected result:
True
False
The lists contain equal elements but are separate objects.
Correct use of is
Use is primarily for singleton comparisons:
if value is None:
...
Do not use is for value comparison:
if status is "completed": # incorrect
...
Use:
if status == "completed":
...
Some small immutable objects may be internally reused. That is an implementation detail and must not be used as application logic.
5. Mutability
A mutable object can change while preserving its identity.
Common mutable built-ins:
list;dict;set;bytearray.
Common immutable built-ins:
int;float;bool;str;tuple;frozenset;bytes.
Mutable example
numbers = [1, 2]
before = id(numbers)
numbers.append(3)
after = id(numbers)
print(before == after)
The list changed, but its identity remained the same.
Immutable example
message = "hello"
before = id(message)
message = message.upper()
after = id(message)
print(message)
print(before == after)
The string itself was not modified. The name was rebound to another string object.
6. Rebinding versus mutation
This difference is one of the most important in Python.
Rebinding
numbers = [1, 2]
numbers = [3, 4]
The name numbers now refers to a different list.
Mutation
numbers = [1, 2]
numbers.append(3)
The existing list object changes.
Function example
def rebind(values):
values = [100, 200]
numbers = [1, 2]
rebind(numbers)
print(numbers)
Output:
[1, 2]
The local name values was rebound. The caller's name numbers was unaffected.
Now compare:
def mutate(values):
values.append(100)
numbers = [1, 2]
mutate(numbers)
print(numbers)
Output:
[1, 2, 100]
The function mutated the shared list object.
7. Function argument semantics
Python is sometimes described as:
- pass by value;
- pass by reference;
- pass by object reference;
- call by sharing.
The most useful practical explanation is:
A function receives new local names bound to the same objects supplied by the caller.
def update(user):
user["active"] = True
When called:
customer = {"active": False}
update(customer)
the local name user and caller name customer refer to the same dictionary.
But this:
def replace(user):
user = {"active": True}
only rebinds the local name.
8. Augmented assignment: +=
+= is subtle because behaviour depends on the object's protocol.
Lists
numbers = [1, 2]
alias = numbers
numbers += [3]
print(numbers)
print(alias)
print(numbers is alias)
Result:
[1, 2, 3]
[1, 2, 3]
True
For lists, += mutates the list in place when possible.
Tuples
numbers = (1, 2)
alias = numbers
numbers += (3,)
print(numbers)
print(alias)
print(numbers is alias)
Result:
(1, 2, 3)
(1, 2)
False
Tuples are immutable. A new tuple is created, and the name is rebound.
Engineering lesson
Never infer mutation solely from syntax. Understand the type's operation.
9. Shallow copying
Create a shallow list copy:
original = [1, 2, 3]
copy = original.copy()
or:
copy = list(original)
or:
copy = original[:]
For a flat list, this may be enough.
For nested structures:
original = [["Berlin"], ["Dresden"]]
copy = original.copy()
copy[0].append("Potsdam")
print(original)
Output:
[['Berlin', 'Potsdam'], ['Dresden']]
The outer list was copied, but nested lists are still shared.
Mental model:
original ──> outer list A ──┐
├──> inner list ["Berlin", "Potsdam"]
copy ──────> outer list B ──┘
10. Deep copying
from copy import deepcopy
original = [["Berlin"], ["Dresden"]]
copy = deepcopy(original)
copy[0].append("Potsdam")
print(original)
print(copy)
Deep copying recursively duplicates nested objects when possible.
However, deepcopy is not automatically a good design.
Problems include:
- expensive memory use;
- expensive CPU use;
- surprising behaviour for custom objects;
- duplicated objects that should remain shared;
- copying resources that cannot sensibly be duplicated;
- hiding unclear ownership.
Often a better solution is:
- immutable data;
- explicit constructors;
- value objects;
- controlled conversion at boundaries;
- clear ownership rules.
11. Tuple immutability is shallow
A tuple cannot replace its element references:
coordinates = (10, 20)
But a tuple can contain a mutable object:
data = ([1, 2], "stable")
data[0].append(3)
print(data)
Output:
([1, 2, 3], 'stable')
The tuple still refers to the same list. The list itself changed.
Therefore:
An immutable container does not make nested mutable objects immutable.
12. Hashability
Dictionary keys and set members must be hashable.
A hashable object generally needs:
- a stable hash value during its lifetime;
- equality behaviour consistent with hashing.
Immutable built-ins are often hashable:
locations = {
("Berlin", "DE"): 3_500_000,
}
Lists are not hashable:
locations = {
["Berlin", "DE"]: 3_500_000,
}
This raises TypeError.
Why? A list can change. If a key changed after insertion, the dictionary might no longer find it in the correct hash bucket.
13. Default argument trap
Predict:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("A"))
print(add_item("B"))
print(add_item("C"))
Output:
['A']
['A', 'B']
['A', 'B', 'C']
Default argument expressions are evaluated when the function definition executes, not every time the function is called.
Therefore, the same list is reused.
Correct version:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Typed version:
from typing import TypeVar
T = TypeVar("T")
def add_item(item: T, items: list[T] | None = None) -> list[T]:
result = [] if items is None else items
result.append(item)
return result
An important design question remains: should the function mutate a caller-provided list? A safer alternative may return a new list:
def with_item(item: T, items: list[T] | None = None) -> list[T]:
existing = [] if items is None else items
return [*existing, item]
14. Dataclasses and mutable defaults
Incorrect:
from dataclasses import dataclass
@dataclass
class Team:
members: list[str] = []
Dataclasses reject many mutable defaults because sharing them would be dangerous.
Correct:
from dataclasses import dataclass, field
@dataclass
class Team:
members: list[str] = field(default_factory=list)
The factory creates a new list for each instance.
first = Team()
second = Team()
first.members.append("Mika")
print(first.members)
print(second.members)
15. Object ownership
Mutation becomes dangerous when ownership is unclear.
Consider:
class UserService:
def __init__(self, default_roles: list[str]) -> None:
self.default_roles = default_roles
Should the service:
- own the list;
- share it with the caller;
- copy it;
- convert it into an immutable tuple?
A defensive design:
class UserService:
def __init__(self, default_roles: list[str]) -> None:
self._default_roles = tuple(default_roles)
Now later modifications to the caller's list do not affect the service.
roles = ["reader"]
service = UserService(roles)
roles.append("admin")
The service still stores only ("reader",).
This is a boundary decision. Copy or freeze data where ownership changes.
16. Equality for custom objects
Without custom equality, separate instances usually compare by identity:
class User:
def __init__(self, user_id: int) -> None:
self.user_id = user_id
first = User(1)
second = User(1)
print(first == second)
This is normally False.
With a dataclass:
from dataclasses import dataclass
@dataclass(frozen=True)
class UserId:
value: int
first = UserId(1)
second = UserId(1)
print(first == second)
This is True because dataclasses can generate value-based equality.
frozen=True also supports a value-object style and can make hashing possible when all fields are hashable.
17. TypeScript comparison
JavaScript and Python both use object sharing for object-like values, but their mental vocabulary differs.
TypeScript:
let count = 10;
count = 20;
Python:
count = 10
count = 20
Both reassign the variable/name.
For objects:
const settings = { debug: false };
const alias = settings;
alias.debug = true;
Python:
settings = {"debug": False}
alias = settings
alias["debug"] = True
The critical Python-specific areas are:
isversus==;- mutable default arguments;
- hashability;
- tuple shallow immutability;
- special-method behaviour for augmented assignment;
- dataclass value semantics;
- binding in closures and scopes.
18. Debugging identity and mutation
Useful tools:
print(id(value))
print(type(value))
print(value is other)
print(value == other)
For nested structures:
print(id(original))
print(id(copy))
print(id(original[0]))
print(id(copy[0]))
Do not build application logic around numeric id() values. Use them as a debugging aid.
A more systematic debugging process:
- Identify all names involved.
- Identify which objects they reference.
- Mark mutable objects.
- Find the operation that mutates or rebinds.
- Determine ownership.
- Decide whether copying, immutability, or documentation is needed.
19. English vocabulary
| Term | Meaning |
|---|---|
| binding | the association between a name and an object |
| reassignment | binding a name to a different object |
| mutation | changing an existing object |
| identity | being the exact same object |
| equality | representing equivalent values |
| alias | another name referring to the same object |
| shallow copy | a new outer container with shared nested objects |
| deep copy | a recursive attempt to duplicate nested objects |
| ownership | responsibility for managing and changing data |
| hashable | usable as a dictionary key or set member |
Professional sentences
- "Both names are bound to the same mutable object."
- "The function rebinds its local parameter but does not replace the caller's binding."
- "This copy is shallow, so the nested lists remain shared."
- "The API does not clearly define ownership of the collection."
- "I would convert this list to a tuple at the boundary."
- "Use equality for values and identity for singleton checks."
20. Speaking task
Explain this program:
def update(values):
values += [4]
numbers = [1, 2, 3]
update(numbers)
print(numbers)
Then explain why the behaviour changes when numbers is a tuple.
Use the terms:
- binding;
- mutation;
- identity;
- augmented assignment;
- immutable.
21. Writing task
Write a 300-word incident explanation for the following production bug:
A service constructor accepted a list of default permissions. Another part of the application later appended "admin" to the original list. Every newly created user unexpectedly received administrator permissions.
Your explanation should include:
- root cause;
- object sharing;
- ownership;
- immediate fix;
- long-term prevention;
- recommended tests.
22. Exercises
Exercise 1: Predict identity and equality
first = [1, 2]
second = first
third = [1, 2]
print(first == second)
print(first is second)
print(first == third)
print(first is third)
Exercise 2: Rebinding or mutation?
For each line, classify the operation.
name = "Steve"
name = name.upper()
numbers = [1, 2]
numbers.append(3)
settings = {"debug": False}
settings["debug"] = True
users = []
users = users + ["Mika"]
users += ["Lily"]
Exercise 3: Fix the function
def register(name, roles=[]):
roles.append("user")
return {"name": name, "roles": roles}
Exercise 4: Nested copy
Predict:
original = {
"teams": [
{"name": "A", "members": ["Mika"]},
]
}
copy = original.copy()
copy["teams"][0]["members"].append("Lily")
print(original)
Exercise 5: Design a safe boundary
Implement a ReportConfig class that accepts:
- a list of columns;
- a dictionary of formatting options.
The object must not change when the caller later mutates the original inputs.
Exercise 6: Value object
Create an immutable, hashable EmailAddress value object that normalizes input to lowercase and compares by normalized value.
23. Complete solutions
Solution 1
True
True
True
False
first and second are two names for the same list. third is a separate list with equal content.
Solution 2
name = "Steve"
name = name.upper()
The second line rebinds name to a new string.
numbers.append(3)
Mutation.
settings["debug"] = True
Mutation.
users = users + ["Mika"]
Creates a new list and rebinds users.
users += ["Lily"]
For a list, usually mutates the existing list in place.
Solution 3
def register(name: str, roles: list[str] | None = None) -> dict[str, object]:
assigned_roles = [] if roles is None else list(roles)
assigned_roles.append("user")
return {
"name": name,
"roles": assigned_roles,
}
Copying the provided roles prevents the function from mutating caller-owned data.
A more strongly modelled version could return a dataclass.
Solution 4
The original nested list is modified:
{'teams': [{'name': 'A', 'members': ['Mika', 'Lily']}]}
Only the outer dictionary was copied. The list under "teams" and all nested objects remained shared.
Solution 5
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Any
class ReportConfig:
def __init__(
self,
columns: Sequence[str],
formatting: Mapping[str, Any],
) -> None:
self._columns = tuple(columns)
self._formatting = MappingProxyType(dict(formatting))
@property
def columns(self) -> tuple[str, ...]:
return self._columns
@property
def formatting(self) -> Mapping[str, Any]:
return self._formatting
The constructor copies both inputs. Columns become an immutable tuple. The formatting dictionary is copied and exposed through a read-only mapping proxy.
Solution 6
from dataclasses import dataclass
@dataclass(frozen=True)
class EmailAddress:
value: str
def __post_init__(self) -> None:
normalized = self.value.strip().lower()
if "@" not in normalized:
raise ValueError("Invalid email address")
object.__setattr__(self, "value", normalized)
first = EmailAddress(" Steve@Example.com ")
second = EmailAddress("steve@example.com")
assert first == second
addresses = {first, second}
assert len(addresses) == 1
Because the dataclass is frozen and contains a hashable string, it can be used as a set member or dictionary key.
24. Chapter checkpoint
You are ready to continue when you can explain:
- Why assignment does not copy an object.
- The difference between identity and equality.
- The difference between mutation and rebinding.
- Why Python is not accurately described as simple pass-by-reference.
- Why shallow copies can be dangerous.
- Why default mutable arguments are reused.
- Why ownership should be explicit at API boundaries.
- How
+=can behave differently for lists and tuples.