Zum Hauptinhalt springen

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:

second contains a reference to first.

That explanation is close, but still imprecise.

A better model is:

first and second are 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:

  1. obtain or create an integer object representing 10;
  2. bind the name score to 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:

  • is versus ==;
  • 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:

  1. Identify all names involved.
  2. Identify which objects they reference.
  3. Mark mutable objects.
  4. Find the operation that mutates or rebinds.
  5. Determine ownership.
  6. Decide whether copying, immutability, or documentation is needed.

19. English vocabulary

TermMeaning
bindingthe association between a name and an object
reassignmentbinding a name to a different object
mutationchanging an existing object
identitybeing the exact same object
equalityrepresenting equivalent values
aliasanother name referring to the same object
shallow copya new outer container with shared nested objects
deep copya recursive attempt to duplicate nested objects
ownershipresponsibility for managing and changing data
hashableusable 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:

  1. Why assignment does not copy an object.
  2. The difference between identity and equality.
  3. The difference between mutation and rebinding.
  4. Why Python is not accurately described as simple pass-by-reference.
  5. Why shallow copies can be dangerous.
  6. Why default mutable arguments are reused.
  7. Why ownership should be explicit at API boundaries.
  8. How += can behave differently for lists and tuples.