TechGuild
1Level 1, Curious Newcomer

Names, objects and the mutation model

Assignment binds a name to an object and copies nothing. Almost every Python surprise a Java or TypeScript engineer hits in their first week follows from that one sentence, so this lesson works through the consequences deliberately.

11 min readfoundation
On this page

What you will be able to do

  • Explain assignment as name binding rather than copying or referencing a slot
  • Predict aliasing behaviour when a mutable object is passed to a function
  • Apply the rule for `is` against `==` without relying on interning
  • Identify the mutable default argument and late-binding closure traps in unfamiliar code

You already know that List<String> b = a in Java gives you two names for one list. Python goes further: there is no second kind of assignment. No primitive copies a value on the way in, no final freezes a binding, and no const protects anything. x = 5 does not put five into a slot called x. It attaches the label x to an integer object.

Nearly every surprise in a first Python week is one surprise wearing five costumes. Work from the model below and you can predict all five instead of memorising them.

The model, in three sentences

Every value is an object with an identity that never changes, a type and a value. A name is a label bound to an object, and a name has no type and no storage of its own. Assignment rebinds the label and copies nothing.

Name binding

Attaching a name to an object. Rebinding one name never disturbs another name bound to the same object. Parameters, loop variables, module-level names, attributes and the targets of an unpacking assignment are all bound this way.

Mutability is a property of the object, never of the name in front of it. list, dict, set and ordinary class instances can be changed in place; int, str, tuple and frozenset cannot. That distinction explains the rest of this lesson.

xs = [1, 2, 3]
ys = xs           # one object, two names
ys.append(4)
print(xs)         # [1, 2, 3, 4]
print(xs is ys)   # True

xs = [9]          # rebinds the label xs, mutates nothing
print(ys)         # [1, 2, 3, 4]
limits = [1, 2]
limits.append(3)   # allowed
limits = []        # also allowed: no keyword can freeze a binding

Both anchors distinguish a frozen binding from a frozen object. Python has only the object.

Passing an argument is another binding

A call binds the parameter names to the objects the caller is already holding. Nothing is copied on the way in, so a mutation inside the function is visible outside it, and a rebinding inside the function is not.

Aliasing

Two or more names bound to one object, so a change made through any of them is visible through all of them. Aliasing is not a defect; it is what makes a large object cheap to pass. It becomes a defect when one side of the code does not know the other side can see the change.

Aliasing also appears where no function is involved. grid = [[0] * 2] * 3 builds one inner list and three references to it, so grid[0][0] = 9 changes what looks like every row. Build nested structures with a comprehension, [[0] * 2 for _ in range(3)], which evaluates the inner expression once per row.

The convention that keeps this readable is worth adopting on day one: a function that mutates its argument returns None, following list.append and list.sort, and a function that returns a new object leaves its input alone. A function that does both is where a caller loses data quietly.

A default is evaluated once, at definition time

The def statement is a statement. It runs, it evaluates the default expressions, and it binds the resulting objects to the function. It does not re-evaluate them per call, so a mutable default is one object shared by every call for the lifetime of the program.

def collect(item, into=[]):
    into.append(item)
    return into

print(collect("a"))         # ['a']
print(collect("b"))         # ['a', 'b'], the same list, still there

print(collect.__defaults__) # (['a', 'b'],) the default is an object you can inspect

The fix is a sentinel default and a rebinding inside the body.

def collect(item, into=None):
    if into is None:
        into = []
    into.append(item)
    return into

The same fix is built into the for value classes: dataclasses require field(default_factory=list) rather than a bare [], for exactly this reason.

A closure captures the variable, not its value

A nested function looks up an enclosing name when it runs, not when it is created. If the loop that created three functions has finished, all three see the loop variable's final binding.

callbacks = []
for name in ("alpha", "beta", "gamma"):
    callbacks.append(lambda: name)

print([c() for c in callbacks])   # ['gamma', 'gamma', 'gamma']

The other fix is functools.partial(render, path), which binds the argument immediately and is usually clearer than a default parameter that exists only to close over something. A TypeScript reader feels this one hardest, because let in a loop gives a fresh binding per iteration and Python does not. Scope resolution itself belongs to functions, arguments and closures.

is against ==

is compares identity, meaning whether two names are bound to the same object. == calls __eq__, which is the question you almost always mean. The two diverge as soon as a value arrives from outside your process.

a = "learn"
b = "".join(["l", "e", "a", "r", "n"])
print(a == b)   # True
print(a is b)   # False

Comparing against a literal directly, mode is "strict", is the one version warns about at compile time. Hiding the literal behind a constant, which is what a real codebase does, removes the warning and keeps the bug.

Truthiness is not the same question as presence

0, 0.0, "", [], {}, set() and None are all falsy, so if x: and if x is not None: ask different questions. Empty and zero are legitimate answers in most programs, which is what makes this expensive.

if items: for an empty container is idiomatic and correct. The rule is narrower: when a value can be absent as well as empty or zero, test for absence explicitly.

Two closing shocks from the same model

There are no private members. A single leading underscore is a convention meaning "not part of the interface", enforced by nothing. A double leading underscore triggers : self.__store inside class Cache is stored as _Cache__store, which prevents an accidental collision in a subclass and stops nobody who wants it. And there is no final: a name in a module, a class or a frame can be rebound at run time, including a method after the class exists. Classes themselves are a later lesson; what matters here is that class attributes and instance attributes are bound by the same rule as everything else.

Check your understanding

Sign in to take this check

4 questions on this lesson, one at a time, with the reasoning for every option as soon as you answer. Each answer is marked on the server and stored against your account.

An account is free. There is no paid plan, no tier and nothing to buy.