Identity, Equality, and Mutability

Updated

September 7, 2026

Identity, Equality, and Mutability

After reading this chapter, you will master the fundamental distinction between identity (is) and equality (==), know how CPython caches small integers and strings, and avoid shared mutable state bugs.

Mental model

In Python, every object has three core attributes: 1. Identity: The memory address where the object resides (id(obj)). 2. Type: What kind of object it is (e.g. int, str, list), which never changes. 3. Value: The data stored in the object.

       Equality (==) compares VALUES
  ┌─────────────────────────────────────┐
  │ "production"            "production"│
  └─────────────────────────────────────┘
        ▲                         ▲
        │                         │
┌───────────────┐         ┌───────────────┐
│ Object A      │         │ Object B      │
│ id: 0x104a    │         │ id: 0x208f    │
└───────────────┘         └───────────────┘
  └─────────────────────────────────────┘
       Identity (is) compares MEMORY IDs
       (Are they the exact same object?)
  • a == b: Evaluates a.__eq__(b). Asks: Do these two objects have equivalent contents?
  • a is b: Evaluates id(a) == id(b). Asks: Are these two variables pointing to the exact same memory address?

Minimal example

Save this file as identity_demo.py:

# identity_demo.py
def main() -> None:
    # Two distinct list objects with identical contents
    list_a = [10, 20, 30]
    list_b = [10, 20, 30]

    print(f"list_a == list_b : {list_a == list_b} (Values match)")
    print(f"list_a is list_b : {list_a is list_b} (Different objects in memory)")
    print(f"id(list_a)       : {hex(id(list_a))}")
    print(f"id(list_b)       : {hex(id(list_b))}")

    # The singleton None must ALWAYS be checked with 'is'
    status = None
    print(f"status is None   : {status is None}")

if __name__ == "__main__":
    main()

Run via uv run python identity_demo.py:

list_a == list_b : True (Values match)
list_a is list_b : False (Different objects in memory)
id(list_a)       : 0x7f5110a0
id(list_b)       : 0x7f511120
status is None   : True

Worked examples

Case 1: The small integer caching mechanism

CPython pre-allocates and caches small integers in the range [-5, 256] during startup. Any reference to an integer in this range shares the pre-allocated singleton instance.

# int_cache.py
def test_caching() -> None:
    # Inside the cache range [-5, 256]
    x = 250
    y = 250
    print(f"250 is 250: {x is y} (Shared singleton from CPython cache)")

    # Outside the cache range (typically > 256)
    big1 = 1000
    big2 = 1000
    print(f"1000 is 1000: {big1 is big2} (Distinct heap allocations)")
    print(f"1000 == 1000: {big1 == big2} (Values are still equal)")

if __name__ == "__main__":
    test_caching()

Run:

uv run python int_cache.py

Output:

250 is 250: True (Shared singleton from CPython cache)
1000 is 1000: False (Distinct heap allocations)
1000 == 1000: True (Values are still equal)

Why: Never rely on is for integer or string equality. Always use == for values.

Case 2: Shallow copy vs Deep copy

When copying nested mutable collections, a shallow copy copies references to inner objects, while a deep copy recursively duplicates everything.

# copy_semantics.py
import copy

def main() -> None:
    original = {"cluster": "prod", "nodes": ["node-1", "node-2"]}

    # Shallow copy
    shallow = original.copy()
    # Deep copy
    deep = copy.deepcopy(original)

    # Mutate the nested list
    original["nodes"].append("node-3")

    print(f"Original nodes: {original['nodes']}")
    print(f"Shallow nodes : {shallow['nodes']} (Mutated because inner list reference was shared)")
    print(f"Deep nodes    : {deep['nodes']} (Isolated because inner list was cloned)")

if __name__ == "__main__":
    main()

Run:

uv run python copy_semantics.py

Pitfalls

Pitfall 1: Comparing values using is

# Bug: Works sometimes due to caching, fails unexpectedly in production!
if user_input is "admin":  # WRONG! Raises SyntaxWarning in modern Python
    pass

# Correct:
if user_input == "admin":  # Correct value equality check
    pass

Pitfall 2: Mutating objects inside tuples

A tuple is immutable, meaning its reference slots cannot be changed. However, if a slot points to a mutable object (like a list), that list can still be mutated in place!

t = (1, [2, 3])
t[1].append(4)  # Perfectly valid: the list inside the tuple is modified!
print(t)        # (1, [2, 3, 4])

Exercises

  1. Write a script comparing two identical strings constructed at runtime (s1 = "hello_world" vs s2 = "".join(["hello", "_", "world"])). Compare them with is and with ==.
  2. Inspect id(None) across multiple variables set to None. Verify they all resolve to the exact same pointer address.
  3. Construct a dictionary with a nested dictionary. Perform both a shallow copy and a copy.deepcopy(). Demonstrate modifying a nested value in the original and check both copies.

Further reading

  • CPython Internal Source: Objects/longobject.c (small integer cache definition).
  • Python Documentation: copy module (Shallow and deep copy operations).
  • PEP 8: Programming Recommendations (Comparisons to singletons like None).