"Names are references" corrects the beginner idea that assignment always copies a value. It is still too compressed for production reasoning. Names are bindings in namespaces; containers and object attributes also hold references; assignment can rebind one edge without changing an object; mutation changes an object visible through every alias; and copying duplicates only a chosen part of an object graph.

The useful model is a graph. Objects are nodes. Namespaces, containers, attributes, closure cells, and frames hold directed edges. Assignment changes edges. Mutation changes a node or its outgoing edges. Lifetime depends on reachability, not on which variable seems to "own" a value.

Python guarantee. Every object has identity, type, and value. Assignment binds names and does not copy arbitrary objects. is tests identity; == asks for equality. Function calls bind argument objects to parameter names.

Version note. Experiments target Python 3.10+ and ran on CPython 3.14.7. CPython currently implements id() using an address-like value and primarily manages lifetime with reference counting; neither is a language guarantee. Compiler opcodes, frame storage, interning, and refcount optimizations are implementation details.

Experiment 1: rebinding and mutation affect different things

first = [1, 2]
second = first

second.append(3)
print(first, second, first is second)

second = [9]
print(first, second, first is second)

append changes the one shared list. The later assignment moves the second binding to another list; it does not detach, clear, or copy the first object. Saying "variables are boxes" encourages the wrong prediction because two boxes appear to contain separate values. Saying "both names point to one object" predicts both lines.

Even "pointer" needs caution. Python references are managed language-level relationships, not integer addresses available for arithmetic. Implementations may represent them differently while preserving identity and behavior.

Identity is lifetime-local

id(obj) is unique and stable while that object lives. Once it is gone, a later object may receive the same ID. Equality can be customized, non-Boolean-looking results can arise in specialized libraries, and equal objects need not be identical.

Use is for documented singleton sentinels, especially None, or when object identity itself is the domain concept. Use == for values. Never persist id() as a durable identifier.

Experiment 2: equality does not collapse identity

left = [1, 2]
right = [1, 2]
alias = left

print(left == right, left is right)
print(left == alias, left is alias)

missing = object()
value = missing
print(value is missing)

Two independent lists compare equal. A private sentinel works because no caller can accidentally manufacture that same object; equality is irrelevant. This is safer than using None when None is valid input.

Do not infer semantics from accidental interning. CPython may reuse some integers, strings, and constants, and optimizations change by context and version. a is b for equal numbers or strings is not a supported value comparison.

Calls bind objects to fresh local names

Python is often labeled "pass by object reference" or "call by sharing." The mechanics matter more than the label: evaluating arguments produces objects, and the function binds parameter names to those objects. Rebinding a parameter is local. Mutating a passed mutable object is visible to callers.

Experiment 3: one function mutates, then rebinds

Pyodide / WebAssembly
def transform(items):
    items.append("inside")
    items = ["replacement"]
    print("[state] function-local items:", items)


outside = ["start"]
transform(outside)
print("[state] caller items:", outside)

The caller retains ['start', 'inside']. The append follows the shared reference; assignment changes only the local items binding. An API should say whether it mutates an argument. Names like sorted versus list.sort help communicate that contract.

Rebinding attributes or elements is itself mutation of another object: user.name = x changes attribute storage, and items[0] = x changes a container edge. "Assignment never mutates" is therefore also too broad. Simple-name assignment rebinds a namespace entry; assignment targets invoke different protocols.

Augmented assignment asks the object first

x += y is not guaranteed to mean x = x + y. It first attempts in-place behavior such as __iadd__, which mutable types can implement by changing themselves. The target is then rebound to the result. Immutable types generally return a new object.

Experiment 4: the same syntax has different alias effects

numbers = [1]
numbers_alias = numbers
numbers += [2]
print(numbers, numbers_alias, numbers is numbers_alias)

text = "a"
text_alias = text
text += "b"
print(text, text_alias, text is text_alias)

The list alias sees [1, 2]; the string alias remains a. This distinction becomes dangerous inside tuples: a tuple may still contain a list that += mutates before tuple item assignment fails. Immutability of a container means its references cannot be replaced, not that all reachable objects are immutable.

Shallow copying duplicates one node

A shallow copy creates a new outer object and reuses references to children. A deep copy recursively copies a graph while preserving internal aliasing through a memo table. Neither operation automatically matches domain ownership.

Experiment 5: inspect a shallow copy's graph

import copy


original = {"labels": ["new"], "count": 1}
shallow = copy.copy(original)

shallow["count"] = 2
shallow["labels"].append("hot")

print(original)
print(shallow)
print(original is shallow)
print(original["labels"] is shallow["labels"])

The outer dictionary and integer-valued edge differ, while the nested list is shared. Constructors and slicing are often shallow-copy operations too. State that property when an API promises a copy.

Deep copying can be expensive or nonsensical for files, sockets, locks, database sessions, modules, and resources with external identity. Classes can customize copying. Prefer an explicit domain operation such as clone_for_retry() when copied and shared fields require careful policy.

Experiment 6: deep copy preserves internal sharing

import copy


shared = [1, 2]
graph = [shared, shared]
clone = copy.deepcopy(graph)

print(clone is graph)
print(clone[0] is shared)
print(clone[0] is clone[1])

The deep copy has a new child, but both cloned edges still target that same cloned child. A naive recursive copier might duplicate it twice or recurse forever on cycles. The standard module's memoization handles these graph properties, though not domain semantics.

Defaults retain references

Default argument expressions execute when the function definition executes, normally during import. The function object then retains those values. A mutable default is shared across calls unless deliberately used as state.

Experiment 7: a default is one persistent object

Pyodide / WebAssembly
def collect(value, bucket=[]):
    bucket.append(value)
    return list(bucket)


print("[state] shared default after first call:", collect("a"))
print("[state] shared default after second call:", collect("b"))
print("[check] retained default bucket:", collect.__defaults__[0])

Use a sentinel and construct per call when sharing is not intended:

Pyodide / WebAssembly
def collect_safely(value, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(value)
    return bucket


print("[result] first independent call:", collect_safely("a"))
print("[result] second independent call:", collect_safely("b"))

This is not a special exception to reference rules. The function's defaults tuple simply supplies another long-lived edge.

Closures capture cells, not snapshots

An inner function can retain a cell associated with an enclosing local after the outer call returns. Multiple closures may share that cell. nonlocal rebinds it; mutation can change a referenced object without rebinding.

Experiment 8: two functions share one binding cell

def counter():
    value = 0

    def increment():
        nonlocal value
        value += 1
        return value

    def inspect():
        return value

    return increment, inspect


increment, inspect = counter()
print(inspect(), increment(), inspect())
print(increment.__closure__[0] is inspect.__closure__[0])

The behavioral sharing is portable. The exposed __closure__ tuple and exact cell ordering are introspection details and should not drive application logic. Closures can also extend lifetime by keeping a large object graph reachable through one small callback.

Ownership is an API decision

The runtime knows references, not business ownership. An API must decide whether it borrows a mutable input, retains it, mutates it, transfers responsibility for cleanup, or snapshots it. Bugs arise when caller and callee assume different policies.

For configuration, copying immutable normalized values at the boundary often prevents later caller mutation. For large buffers, borrowing may avoid expensive copies but requires a clear lifetime rule. For async work, retaining a caller-owned mutable object can create races between submission and execution. A frozen record, bytes object, or explicit snapshot can make the contract enforceable.

Returning internal mutable containers leaks an edge into private state. Return an iterator, immutable view, tuple, or deliberate copy when callers should not mutate internals. Conversely, avoid ritual copying without a threat model; copies cost time and memory and may still be shallow.

Debug object graphs in terms of retaining paths. Globals, exception tracebacks, task objects, callbacks, caches, and interactive history can all hold edges. sys.getrefcount is CPython-specific and includes temporary diagnostic references. gc.get_referrers can itself create or expose edges. Tools are evidence, not ownership definitions.

Concurrency makes alias policy observable

Sharing a mutable object between threads or tasks does not copy it. Even when individual built-in operations happen to be protected by the current CPython GIL, a sequence such as read-check-update is not an application-level transaction. Free-threaded CPython builds sharpen this distinction, but the design rule is already portable: synchronize shared mutation or transfer immutable snapshots.

Async code creates a particularly quiet gap. A coroutine can retain references across every await; another task may mutate the same object before execution resumes. Capture the value needed after the suspension, copy at the submission boundary, or document that the caller must not mutate until completion. Holding a local name keeps an object alive but does not freeze its value.

Views deliberately expose ongoing aliasing. Dictionary view objects reflect later mapping changes; memoryview can share writable buffer storage; iterators often retain and observe their source. Returning a view can avoid a copy and communicate live data, but it needs stronger lifetime and mutation rules than returning a tuple snapshot.

Dataclasses, named tuples, and frozen records choose different graph policies. A frozen data class prevents ordinary rebinding of its fields, yet a field can still refer to a mutable list. "Frozen" is shallow. True deep immutability requires immutable reachable values or a domain discipline that controls them. The same caveat applies to tuples and read-only mapping proxies.

API reviews should name each boundary's policy in verbs: consume, borrow, retain, mutate, snapshot, or transfer. For a retained callback, state how it is unregistered. For a buffer, state whether it may be changed during the call and after return. For a result container, state whether modifications affect service state. These contracts prevent more bugs than debating whether Python is pass-by-reference or pass-by-value.

When performance motivates sharing, measure copy cost against synchronization and debugging cost. Immutable snapshots can reduce coordination and make retries reproducible. Large zero-copy buffers can be appropriate, but leases or context managers may be needed so producers know when reuse is safe. Reference semantics provide the mechanism; the application must provide ownership.

Exercises

  1. Draw the object graph before and after a shallow copy of a nested dictionary, then verify every identity claim.
  2. Design a function that accepts a mutable buffer. Document whether it borrows, retains, mutates, or snapshots it.
  3. Demonstrate a tuple containing a list where += mutates the list and then raises during tuple assignment.
  4. Replace a mutable default in real code and add a test proving calls are independent.
  5. Find a closure or callback retaining state and identify the smallest edge whose removal releases it.

Keep this model

Names are namespace bindings, not storage boxes inside objects. They are only one source of graph edges. Calls create local bindings; mutation remains visible through aliases; shallow copy duplicates one node; deep copy follows a policy over a graph; defaults and closures can retain edges far longer than their source line suggests.

Once this model is clear, the engineering questions become explicit: who may mutate, who retains, what is copied, and how long must an object remain reachable? Python cannot answer those domain questions. A good API does.

Primary sources