Names, Binding, and Object References

Updated

September 7, 2026

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.py

Output:

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.py

Output:

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 dictionaries

Pitfall 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

  1. Create a script where two variables p and q point to the same empty list. Append an integer using p. Print q and verify that id(p) == id(q).
  2. Create a script with an integer x = 500. Print its id(). Multiply x by 2 (x *= 2). Print id(x) again. Why did the memory address change?
  3. 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.
  4. 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.