Names, Binding, and Object References
Names, Binding, and Object References
After reading this chapter, you will understand how Python binds identifiers to heap-allocated objects, how assignment actually works in CPython, and how to inspect memory references with confidence.
Mental model
In compiled languages like C or Go, a variable is a named memory slot with a fixed size and type. In Python, variables are not boxes holding values; they are name tags (pointers) bound to objects living on the heap.
Stack / Namespace (Frame) Heap Memory (PyObject)
┌──────────────────────┐ ┌───────────────────────────────────┐
│ name: "port" │────────────▶│ PyLongObject (value: 8080) │
│ │ │ ob_refcnt: 2 │
└──────────────────────┘ │ ob_type: <class 'int'> │
┌──────────────────────┐ └───────────────────────────────────┘
│ name: "service_port" │────────────┘ ▲
└──────────────────────┘ │
┌─────────────────┴─────────────────┐
┌──────────────────────┐ │ PyLongObject (value: 9000) │
│ name: "backup_port" │────────────▶│ ob_refcnt: 1 │
└──────────────────────┘ └───────────────────────────────────┘
When you execute port = 8080, Python creates a PyLongObject representing 8080 in heap memory, and binds the string key "port" in the current namespace dictionary to that object’s memory address. When you execute service_port = port, no integer is copied; both identifiers simply reference the exact same memory address.
Minimal example
Save the following file as binding_demo.py and run it via uv run python binding_demo.py:
# binding_demo.py
def main() -> None:
# 1. Bind name "a" to an integer object
a = 1000
# 2. Bind name "b" to the same object "a" points to
b = a
print(f"a = {a}, id(a) = {hex(id(a))}")
print(f"b = {b}, id(b) = {hex(id(b))}")
print(f"a is b: {a is b}")
# 3. Rebind "a" to a completely new integer object
a = 2000
print("\nAfter rebinding a = 2000:")
print(f"a = {a}, id(a) = {hex(id(a))}")
print(f"b = {b}, id(b) = {hex(id(b))}")
print(f"a is b: {a is b}")
if __name__ == "__main__":
main()Output:
a = 1000, id(a) = 0x7f884120
b = 1000, id(b) = 0x7f884120
a is b: True
After rebinding a = 2000:
a = 2000, id(a) = 0x7f884140
b = 1000, id(b) = 0x7f884120
a is b: False
Rebinding a did not mutate the number 1000. It simply changed what a points to.
Worked examples
Case 1: Inspecting the namespace dictionary
Python implements namespaces as standard hash tables (dictionaries). You can inspect them directly using locals() and globals().
# namespace_inspect.py
def inspect_scope() -> None:
region = "us-east-1"
replicas = 3
scope = locals()
print("Local namespace keys:", [k for k in scope if not k.startswith("_")])
print(f"Value of region: {scope['region']}")
print(f"Value of replicas: {scope['replicas']}")
if __name__ == "__main__":
inspect_scope()Run:
uv run python namespace_inspect.pyOutput:
Local namespace keys: ['region', 'replicas']
Value of region: us-east-1
Value of replicas: 3
Why: Every variable lookup in Python begins by searching these internal namespace mapping tables.
Case 2: Binding vs in-place mutation
Because names are references, mutating an object in place affects all names pointing to it. Rebinding a name does not.
# mutate_vs_rebind.py
def demonstrate() -> None:
# Scenario A: In-place mutation
list_x = [1, 2, 3]
list_y = list_x
list_x.append(4)
print("Scenario A (Mutation):")
print(f"list_x: {list_x}")
print(f"list_y: {list_y} (affected because both share the same reference!)")
# Scenario B: Rebinding
list_x = [10, 20]
print("\nScenario B (Rebinding list_x):")
print(f"list_x: {list_x}")
print(f"list_y: {list_y} (unaffected because list_x was rebound to a new object)")
if __name__ == "__main__":
demonstrate()Run:
uv run python mutate_vs_rebind.pyOutput:
Scenario A (Mutation):
list_x: [1, 2, 3, 4]
list_y: [1, 2, 3, 4] (affected because both share the same reference!)
Scenario B (Rebinding list_x):
list_x: [10, 20]
list_y: [1, 2, 3, 4] (unaffected because list_x was rebound to a new object)
Why: list_x.append() alters the existing object in heap memory. list_x = [10, 20] creates a new list and updates the pointer in list_x.
Pitfalls
Pitfall 1: Expecting assignment to copy compound objects
# Dangerous:
original = {"host": "db.internal", "port": 5432}
copy_config = original
copy_config["port"] = 5433 # Also alters original["port"]!
# Correct fix:
copy_config = original.copy() # Shallow copy for flat dictionariesPitfall 2: Attempting to modify immutable objects
Numbers, strings, and tuples are immutable. When you write s = "hello"; s += " world", Python does not extend "hello" in place; it allocates a brand-new string "hello world" and rebinds s.
Exercises
- Create a script where two variables
pandqpoint to the same empty list. Append an integer usingp. Printqand verify thatid(p) == id(q). - Create a script with an integer
x = 500. Print itsid(). Multiplyxby 2 (x *= 2). Printid(x)again. Why did the memory address change? - Write a function
swap_names()that demonstrates swapping two variable references using tuple packing:a, b = b, a. Verify that their memory identities switch places. - Using
sys.getrefcount(), observe the reference count of a newly created custom list before and after creating two aliases to it.
Further reading
- Python Documentation: Execution Model and Naming (Docs -> Reference -> Execution Model).
- PEP 3104: Access to Names in Outer Scopes.
- Ned Batchelder: Facts and Myths about Python names and values.