← Back to list

Understanding Python’s Memory Model: Mutability, Identity, and Function Arguments

Understanding Python’s Memory Model: Mutability, Identity, and Function Arguments

Irakoze Elie · 2025-11-10 12:42 · 0 claps · 15.9 min read
#python #identity #functional-programming #mutable-objects #immutable-objects
Open on Medium ↗
Wiki topics: 💻 · Programming

Understanding Python’s Memory Model: Mutability, Identity, and Function Arguments

Introduction

Python’s approach to memory management and object mutability is both elegant and sometimes surprising for developers coming from other programming languages. Understanding how Python handles objects in memory, the difference between mutable and immutable objects, and how arguments are passed to functions is crucial for writing efficient, bug-free code. This blog post explores Python’s object model, diving deep into identity, types, mutability, and the fascinating optimizations CPython implements under the hood. Whether you’re debugging unexpected behavior or optimizing your code, mastering these concepts will make you a more effective Python programmer.

ID and Type: Every Object’s Identity Card

In Python, every object has three fundamental characteristics: an identity, a type, and a value. The identity is the object’s unique address in memory, which you can retrieve using the id() function. Think of it as the object's social security number—it never changes during the object's lifetime. The type, accessed via the type() function, determines what operations the object supports and whether it's mutable or immutable.

# Examining identity and type
x = 42
y = 42
z = x
print(f"x id: {id(x)}")  # Output: 140234567890123 (example address)
print(f"y id: {id(y)}")  # Output: 140234567890123 (same for small integers!)
print(f"z id: {id(z)}")  # Output: 140234567890123 (z references same object as x)
print(f"x type: {type(x)}")  # Output: <class 'int'>
print(f"x == y: {x == y}")   # Output: True (same value)
print(f"x is y: {x is y}")   # Output: True (same identity for small integers)
# Different objects with same value
list1 = [1, 2, 3]
list2 = [1, 2, 3]
print(f"list1 id: {id(list1)}")  # Output: 140234598760123
print(f"list2 id: {id(list2)}")  # Output: 140234598761456 (different!)
print(f"list1 == list2: {list1 == list2}")  # Output: True (same value)
print(f"list1 is list2: {list1 is list2}")  # Output: False (different objects)

The is operator checks if two variables reference the exact same object in memory (comparing identities), while == checks if their values are equal. This distinction becomes critical when working with mutable objects.

Mutable Objects: Objects That Can Change

Mutable objects are containers whose content can be modified after creation without changing the object’s identity. When you modify a mutable object, you’re changing its value in place, but the object’s address in memory remains the same. Python’s primary mutable types are lists, dictionaries, sets, and bytearrays.

# Lists are mutable
my_list = [1, 2, 3]
print(f"Original list id: {id(my_list)}")  # Output: 140234598760123
print(f"Original list: {my_list}")         # Output: [1, 2, 3]
my_list.append(4)
print(f"After append id: {id(my_list)}")   # Output: 140234598760123 (same!)
print(f"After append: {my_list}")          # Output: [1, 2, 3, 4]
my_list[0] = 100
print(f"After modification id: {id(my_list)}")  # Output: 140234598760123 (still same!)
print(f"After modification: {my_list}")         # Output: [100, 2, 3, 4]
# Dictionaries are mutable
my_dict = {"name": "Alice", "age": 30}
print(f"Original dict id: {id(my_dict)}")  # Output: 140234598761789
my_dict["city"] = "New York"
print(f"After adding key id: {id(my_dict)}")  # Output: 140234598761789 (unchanged)
print(f"Modified dict: {my_dict}")  # Output: {'name': 'Alice', 'age': 30, 'city': 'New York'}
# Sets are mutable
my_set = {1, 2, 3}
print(f"Original set id: {id(my_set)}")  # Output: 140234598762456
my_set.add(4)
print(f"After add id: {id(my_set)}")     # Output: 140234598762456 (unchanged)
print(f"Modified set: {my_set}")         # Output: {1, 2, 3, 4}
# Bytearrays are mutable
my_bytes = bytearray(b"hello")
print(f"Original bytearray id: {id(my_bytes)}")  # Output: 140234598763123
my_bytes[0] = 72  # 'H' in ASCII
print(f"After modification id: {id(my_bytes)}")  # Output: 140234598763123
print(f"Modified bytearray: {my_bytes}")         # Output: bytearray(b'Hello')

The key takeaway is that mutable objects maintain their identity even when their contents change, which has significant implications for aliasing and function arguments.

Immutable Objects: Objects Frozen in Time

Immutable objects cannot be modified after creation. Any operation that appears to modify an immutable object actually creates a new object with a new identity. Python’s immutable types include numbers (integers, floats, complex numbers), strings, tuples, frozen sets, and bytes.

# Integers are immutable
x = 10
print(f"Original x id: {id(x)}")  # Output: 140234567890456
x = x + 5
print(f"After addition id: {id(x)}")  # Output: 140234567890789 (different!)
print(f"New value: {x}")              # Output: 15
# Strings are immutable
s = "hello"
print(f"Original string id: {id(s)}")  # Output: 140234598764123
s = s + " world"
print(f"After concatenation id: {id(s)}")  # Output: 140234598765456 (different!)
print(f"New string: {s}")                  # Output: hello world
# Tuples are immutable
t = (1, 2, 3)
print(f"Original tuple id: {id(t)}")  # Output: 140234598766789
# t[0] = 10  # This would raise TypeError: 'tuple' object does not support item assignment
t = t + (4,)
print(f"After concatenation id: {id(t)}")  # Output: 140234598767123 (different!)
print(f"New tuple: {t}")                   # Output: (1, 2, 3, 4)
# Frozen sets are immutable
fs = frozenset([1, 2, 3])
print(f"Original frozenset id: {id(fs)}")  # Output: 140234598768456
# fs.add(4)  # This would raise AttributeError: 'frozenset' object has no attribute 'add'
fs = fs | {4}  # Creates a new frozenset
print(f"After union id: {id(fs)}")    # Output: 140234598769789 (different!)
print(f"New frozenset: {fs}")         # Output: frozenset({1, 2, 3, 4})
# Bytes are immutable
b = b"hello"
print(f"Original bytes id: {id(b)}")  # Output: 140234598770123
# b[0] = 72  # This would raise TypeError: 'bytes' object does not support item assignment
b = b"Hello"
print(f"New bytes id: {id(b)}")  # Output: 140234598771456 (different!)

With immutable objects, every “modification” is actually a rebinding operation that creates a new object and makes the variable point to it.

Why Mutability Matters: Python’s Different Treatment

The distinction between mutable and immutable objects affects how Python manages memory, handles assignments, and prevents or allows side effects. Python treats mutable and immutable objects fundamentally differently when it comes to assignment and aliasing. With immutable objects, assignment creates independent references, but with mutable objects, it creates aliases that share the same underlying data.

# Immutable objects: assignment vs referencing
a = 10
b = a  # b references the same object as a
print(f"a id: {id(a)}, b id: {id(b)}")  # Output: Same ids
print(f"a is b: {a is b}")               # Output: True
a = a + 5  # Creates a NEW object
print(f"a id: {id(a)}, b id: {id(b)}")  # Output: Different ids now
print(f"a: {a}, b: {b}")                # Output: a: 15, b: 10
print(f"a is b: {a is b}")               # Output: False
# Mutable objects: the aliasing problem
list_a = [1, 2, 3]
list_b = list_a  # list_b is an ALIAS of list_a
print(f"list_a id: {id(list_a)}, list_b id: {id(list_b)}")  # Output: Same ids
print(f"list_a is list_b: {list_a is list_b}")              # Output: True
list_a.append(4)  # Modifies the shared object
print(f"list_a: {list_a}")  # Output: [1, 2, 3, 4]
print(f"list_b: {list_b}")  # Output: [1, 2, 3, 4] (also modified!)
# To create an independent copy, use slicing or copy methods
list_c = list_a[:]  # or list_a.copy()
list_c.append(5)
print(f"list_a: {list_a}")  # Output: [1, 2, 3, 4]
print(f"list_c: {list_c}")  # Output: [1, 2, 3, 4, 5]

This behavior means that with mutable objects, you need to be careful about aliasing, where multiple variables reference the same object. Changes through one variable affect all aliases. Python optimizes immutable objects by reusing them when possible, since they can never change.

Function Arguments: Pass by Assignment

Python uses a mechanism often described as “pass by assignment” or “pass by object reference.” When you pass an argument to a function, Python creates a new local variable in the function’s namespace that references the same object. This has different implications depending on whether the object is mutable or immutable.

# Immutable objects in functions
def modify_number(n):
    print(f"Inside function, before: n = {n}, id = {id(n)}")
    n = n + 10  # Creates a NEW object, rebinds local variable n
    print(f"Inside function, after: n = {n}, id = {id(n)}")
    return n
x = 5
print(f"Before function call: x = {x}, id = {id(x)}")
result = modify_number(x)
print(f"After function call: x = {x}, id = {id(x)}")  # x unchanged!
print(f"Result: {result}")
# Output:
# Before function call: x = 5, id = 140234567890456
# Inside function, before: n = 5, id = 140234567890456
# Inside function, after: n = 15, id = 140234567890789
# After function call: x = 5, id = 140234567890456
# Result: 15
# Mutable objects in functions: side effects!
def modify_list(lst):
    print(f"Inside function, before: lst = {lst}, id = {id(lst)}")
    lst.append(4)  # Modifies the ORIGINAL object
    print(f"Inside function, after: lst = {lst}, id = {id(lst)}")
my_list = [1, 2, 3]
print(f"Before function call: my_list = {my_list}, id = {id(my_list)}")
modify_list(my_list)
print(f"After function call: my_list = {my_list}, id = {id(my_list)}")  # Changed!
# Output:
# Before function call: my_list = [1, 2, 3], id = 140234598760123
# Inside function, before: lst = [1, 2, 3], id = 140234598760123
# Inside function, after: lst = [1, 2, 3, 4], id = 140234598760123
# After function call: my_list = [1, 2, 3, 4], id = 140234598760123
# Reassignment vs modification in functions
def reassign_list(lst):
    lst = [100, 200, 300]  # Rebinds LOCAL variable, doesn't affect original
    print(f"Inside function: lst = {lst}, id = {id(lst)}")
my_list2 = [1, 2, 3]
print(f"Before: my_list2 = {my_list2}, id = {id(my_list2)}")
reassign_list(my_list2)
print(f"After: my_list2 = {my_list2}, id = {id(my_list2)}")  # Unchanged!
# Output:
# Before: my_list2 = [1, 2, 3], id = 140234598761456
# Inside function: lst = [100, 200, 300], id = 140234598762789
# After: my_list2 = [1, 2, 3], id = 140234598761456

This demonstrates that modifications to mutable objects inside functions affect the original object, while reassignment only affects the local variable. To avoid unintended side effects, create a copy of mutable arguments if you need to modify them without affecting the original.

Integer Pre-allocation: CPython’s Performance Optimization

CPython, the standard Python implementation, pre-allocates a range of small integers when it starts up. Specifically, it creates integer objects for all values from -5 to 256 and stores them in memory. These pre-allocated integers are defined by the constants NSMALLNEGINTS (5) and NSMALLPOSINTS (257), giving us the range [-5, 256]. This optimization exists because these integers are used extremely frequently in typical Python programs—think array indices, loop counters, small constants, and return codes.

# Small integers: pre-allocated and shared
a = 100
b = 100
print(f"a is b: {a is b}")  # Output: True
print(f"id(a): {id(a)}, id(b): {id(b)}")  # Output: Same id
# Even across different contexts
x = 256
y = 256
print(f"x is y: {x is y}")  # Output: True
# But not for integers outside the pre-allocated range
big_a = 257
big_b = 257
print(f"big_a is big_b: {big_a is big_b}")  # Output: False (in most contexts)
print(f"id(big_a): {id(big_a)}, id(big_b): {id(big_b)}")  # Output: Different ids
# Even larger integers definitely create separate objects
large_a = 1000
large_b = 1000
print(f"large_a is large_b: {large_a is large_b}")  # Output: False
print(f"id(large_a): {id(large_a)}, id(large_b): {id(large_b)}")  # Different ids
# Demonstrating the boundary
for i in [254, 255, 256, 257, 258]:
    a = i
    b = i
    print(f"i={i}: a is b = {a is b}, id(a)={id(a)}, id(b)={id(b)}")
# Output shows that 256 and below share identity, 257 and above may not

Why these specific values? Python’s designers analyzed typical code patterns and found that integers in this range account for the vast majority of integer usage. Array indices start at 0, most loops iterate over small ranges, boolean values map to 0 and 1, and common constants like HTTP status codes fall within this range. By pre-allocating these objects, Python avoids the overhead of repeatedly creating and destroying the same small integer objects, improving both performance and memory efficiency. The small negative range handles common cases like -1 (used for “not found” or reverse indexing) and error codes.

Aliases: Multiple Names for the Same Object

An alias occurs when multiple variables reference the same object in memory. This concept is central to understanding Python’s behavior with mutable objects. When variables are aliases, modifying the object through one variable affects all other aliases since they all point to the same object.

# Creating aliases with mutable objects
original = [1, 2, 3]
alias1 = original
alias2 = original
another_ref = alias1
print(f"original id: {id(original)}")    # Output: 140234598760123
print(f"alias1 id: {id(alias1)}")        # Output: 140234598760123 (same)
print(f"alias2 id: {id(alias2)}")        # Output: 140234598760123 (same)
print(f"another_ref id: {id(another_ref)}")  # Output: 140234598760123 (same)
print(f"All point to same object: {original is alias1 is alias2 is another_ref}")  # True
# Modifying through any alias affects all
alias1.append(4)
print(f"original: {original}")      # Output: [1, 2, 3, 4]
print(f"alias2: {alias2}")          # Output: [1, 2, 3, 4]
print(f"another_ref: {another_ref}")  # Output: [1, 2, 3, 4]
# Memory diagram visualization
print("\nMemory representation:")
print("Memory address: 140234598760123")
print("Object value: [1, 2, 3, 4]")
print("Variables pointing to it: original, alias1, alias2, another_ref")
# Breaking the alias by reassignment
alias1 = [5, 6, 7]  # Creates a NEW object, alias1 no longer an alias
print(f"\nAfter reassignment:")
print(f"original: {original}, id: {id(original)}")  # Output: [1, 2, 3, 4], same id
print(f"alias1: {alias1}, id: {id(alias1)}")      # Output: [5, 6, 7], different id
print(f"alias2: {alias2}, id: {id(alias2)}")      # Output: [1, 2, 3, 4], same id
# With immutable objects, reassignment is the only "modification"
str1 = "hello"
str2 = str1  # Both reference same string object
print(f"\nstr1 id: {id(str1)}, str2 id: {id(str2)}")  # Same id
print(f"str1 is str2: {str1 is str2}")  # True
str1 = str1 + " world"  # Creates new object, str1 rebinded
print(f"After modification:")
print(f"str1: {str1}, id: {id(str1)}")  # New string, different id
print(f"str2: {str2}, id: {id(str2)}")  # Original string, same id as before

Understanding aliases is crucial for avoiding bugs where you think you’re working with independent copies but are actually modifying shared data. This is especially important in functions, where parameter passing creates aliases to the original arguments.

The Special Case: Tuples and Frozen Sets

While tuples and frozen sets are immutable, they present an interesting edge case: they can contain mutable objects. The tuple or frozen set itself cannot be modified (you can’t add, remove, or replace elements), but if it contains mutable objects, those objects can still be modified in place. This creates a scenario where an “immutable” container holds changeable content.

# Tuple containing immutable objects: truly immutable
simple_tuple = (1, 2, "hello")
print(f"simple_tuple: {simple_tuple}, id: {id(simple_tuple)}")
# simple_tuple[0] = 10  # TypeError: 'tuple' object does not support item assignment
# Tuple containing mutable objects: partially mutable!
mutable_list = [1, 2, 3]
mixed_tuple = (mutable_list, "immutable_string", 42)
print(f"\nBefore modification:")
print(f"mixed_tuple: {mixed_tuple}, id: {id(mixed_tuple)}")
print(f"mutable_list id: {id(mixed_tuple[0])}")
# Can't change what the tuple contains
# mixed_tuple[0] = [4, 5, 6]  # TypeError
# But can modify the mutable object inside the tuple!
mixed_tuple[0].append(4)
print(f"\nAfter modification:")
print(f"mixed_tuple: {mixed_tuple}, id: {id(mixed_tuple)}")  # Same tuple id
print(f"mutable_list id: {id(mixed_tuple[0])}")  # Same list id
print(f"mutable_list: {mutable_list}")  # Also changed since it's the same object!
# This has implications for hashing
try:
    hash(simple_tuple)  # Works fine
    print(f"\nsimple_tuple is hashable: {hash(simple_tuple)}")
except TypeError as e:
    print(f"Error: {e}")
try:
    hash(mixed_tuple)  # Fails because list is unhashable
    print("mixed_tuple is hashable")
except TypeError as e:
    print(f"\nmixed_tuple is not hashable: {e}")
# Frozen sets with mutable objects
inner_list = [1, 2, 3]
try:
    problematic_frozenset = frozenset([inner_list, "text"])
except TypeError as e:
    print(f"\nCannot create frozenset with mutable elements: {e}")
# Frozen sets can only contain immutable (hashable) objects
valid_frozenset = frozenset([1, 2, "hello", (3, 4)])
print(f"\nvalid_frozenset: {valid_frozenset}")
# Nested tuples: fully immutable if all contents are immutable
nested_immutable = ((1, 2), (3, 4), "text")
print(f"\nNested immutable tuple: {nested_immutable}")
print(f"Hashable: {hash(nested_immutable)}")  # Works!
# Nested tuples with mutable content
nested_with_list = ((1, 2), [3, 4], "text")
nested_with_list[1].append(5)
print(f"Nested tuple after modifying list: {nested_with_list}")

This demonstrates an important principle: immutability is not transitive in Python. An immutable container guarantees its structure won’t change, but not necessarily its content. For tuples and frozen sets to be truly immutable (and hashable), all their contents must also be immutable. This matters when using these objects as dictionary keys or set members, where hashability is required.

Memory Representation Examples

Let’s visualize how Python manages objects in memory with concrete examples showing the relationship between variables, references, and objects.

# Example 1: Immutable objects and rebinding
print("=== Example 1: Integer Assignment ===")
a = 10
b = 10
c = a
print(f"a = {a}, id = {id(a)}")
print(f"b = {b}, id = {id(b)}")
print(f"c = {c}, id = {id(c)}")
print(f"a is b is c: {a is b is c}")  # True for small integers
# Memory visualization:
# Memory Address: 0x1234 (example)
# ┌─────────────┐
# │  int: 10    │ ← a, b, c all point here
# └─────────────┘
a = 20  # Creates new object, rebinds a
print(f"\nAfter a = 20:")
print(f"a = {a}, id = {id(a)}")  # Different id
print(f"b = {b}, id = {id(b)}")  # Unchanged
print(f"c = {c}, id = {id(c)}")  # Unchanged
# New memory state:
# Memory Address: 0x1234          Memory Address: 0x5678
# ┌─────────────┐                ┌─────────────┐
# │  int: 10    │ ← b, c         │  int: 20    │ ← a
# └─────────────┘                └─────────────┘
# Example 2: Mutable objects and aliasing
print("\n=== Example 2: List Aliasing ===")
list1 = [1, 2, 3]
list2 = list1
list3 = [1, 2, 3]  # Different object, same value
print(f"list1 = {list1}, id = {id(list1)}")
print(f"list2 = {list2}, id = {id(list2)}")
print(f"list3 = {list3}, id = {id(list3)}")
print(f"list1 is list2: {list1 is list2}")  # True (aliases)
print(f"list1 is list3: {list1 is list3}")  # False (different objects)
# Memory visualization:
# Memory Address: 0xAAA0          Memory Address: 0xBBB0
# ┌─────────────────┐            ┌─────────────────┐
# │ list: [1, 2, 3] │ ← list1    │ list: [1, 2, 3] │ ← list3
# └─────────────────┘   list2 →  └─────────────────┘
list1.append(4)
print(f"\nAfter list1.append(4):")
print(f"list1 = {list1}")  # [1, 2, 3, 4]
print(f"list2 = {list2}")  # [1, 2, 3, 4] (also modified!)
print(f"list3 = {list3}")  # [1, 2, 3] (unchanged)
# Updated memory:
# Memory Address: 0xAAA0             Memory Address: 0xBBB0
# ┌────────────────────┐            ┌─────────────────┐
# │ list: [1, 2, 3, 4] │ ← list1    │ list: [1, 2, 3] │ ← list3
# └────────────────────┘   list2 →  └─────────────────┘
# Example 3: Function parameter passing
print("\n=== Example 3: Function Parameters ===")
def modify_values(num, lst):
    print(f"Inside function (before):")
    print(f"  num = {num}, id = {id(num)}")
    print(f"  lst = {lst}, id = {id(lst)}")

    num = num + 10  # Creates new int object
    lst.append(100)  # Modifies existing list object

    print(f"Inside function (after):")
    print(f"  num = {num}, id = {id(num)}")
    print(f"  lst = {lst}, id = {id(lst)}")
x = 5
my_list = [1, 2]
print(f"Before function:")
print(f"x = {x}, id = {id(x)}")
print(f"my_list = {my_list}, id = {id(my_list)}")
modify_values(x, my_list)
print(f"After function:")
print(f"x = {x}, id = {id(x)}")          # Unchanged
print(f"my_list = {my_list}, id = {id(my_list)}")  # Modified!
# Memory diagram for function call:
# Before: x (0x1111) → int:5    my_list (0x2222) → [1, 2]
# During: num (0x1111) → int:5  lst (0x2222) → [1, 2]
#         num (0x3333) → int:15 lst (0x2222) → [1, 2, 100]
# After:  x (0x1111) → int:5    my_list (0x2222) → [1, 2, 100]

These examples illustrate how Python’s memory model works in practice, showing the crucial difference between creating new objects (with immutable types or reassignment) versus modifying existing objects (with mutable types).

Additional Paragraphs for Blog Post

Assignment vs Referencing: The Core Distinction

In Python, it’s crucial to understand the difference between assignment and referencing. Assignment doesn’t copy an object’s value into a new memory location; instead, it creates a reference (or pointer) to the existing object. When you write b = a, you're not duplicating the data—you're creating a new name (b) that references the same object that a references. This is why the id() of both variables is identical. Referencing means multiple variable names can point to the same object in memory. For immutable objects, this distinction might seem academic since you can't modify the object anyway, but for mutable objects, it's critical. If you want an actual copy of a mutable object rather than just another reference to it, you must explicitly create one using methods like list.copy(), dict.copy(), slicing (my_list[:]), or the copy module. Without this understanding, you might accidentally create aliases when you intended to create independent copies, leading to bugs where modifying one variable unexpectedly changes another.

# Demonstration of assignment vs referencing
original_list = [1, 2, 3]
# This is referencing (creating an alias)
reference = original_list
print(f"original_list id: {id(original_list)}")  # Output: 140234598760123
print(f"reference id: {id(reference)}")          # Output: 140234598760123 (same!)
reference.append(4)
print(f"original_list: {original_list}")  # Output: [1, 2, 3, 4] (modified!)
# This is creating a copy (new object)
copy = original_list[:]  # or original_list.copy()
print(f"copy id: {id(copy)}")  # Output: 140234598761456 (different!)
copy.append(5)
print(f"original_list: {original_list}")  # Output: [1, 2, 3, 4] (unchanged)
print(f"copy: {copy}")                    # Output: [1, 2, 3, 4, 5]
# With immutable objects, reassignment is the only way to "change" values
x = 10
y = x  # y references the same object as x
print(f"x id: {id(x)}, y id: {id(y)}")  # Same ids
x = 20  # Creates NEW object, rebinds x
print(f"x: {x}, y: {y}")  # Output: x: 20, y: 10
print(f"x id: {id(x)}, y id: {id(y)}")  # Now different ids

Why -5 to 256? The Science Behind NSMALLNEGINTS and NSMALLPOSINTS

The choice of pre-allocating integers from -5 to 256 (NSMALLNEGINTS = 5 and NSMALLPOSINTS = 257) is not arbitrary—it's based on extensive analysis of real-world Python code patterns and frequency analysis of integer usage. These are simply the most commonly used integers in typical Python programs. Zero and positive small numbers (0-100) dominate because they're used as array indices, loop counters, and range boundaries. Think about how often you write for i in range(10) or access list[0]. The number 1 appears constantly in increment operations, boolean conversions (True maps to 1, False to 0), and as a common constant. Numbers up to 256 cover all single-byte values, which is important for byte-level operations, ASCII character codes, and network programming where values like HTTP status codes (200, 404, 500) fall within this range. The small negative range (-5 to -1) handles common special cases: -1 is ubiquitous for "not found" operations, reverse indexing (my_list[-1]), and error codes; -2, -3, -4, and -5 are also used for reverse indexing and special return values. By pre-allocating exactly these integers, CPython optimizes for the actual usage patterns observed in millions of lines of Python code, ensuring that the most frequently accessed integers are always available in memory without the overhead of repeated object creation and destruction. This is a perfect example of how Python prioritizes practical performance optimization based on empirical data rather than theoretical considerations.

# Demonstrating the boundaries of pre-allocated integers
print("=== Testing integer pre-allocation boundaries ===\n")
# Within pre-allocated range: objects are reused
print("Small positive integers (pre-allocated):")
for i in [0, 1, 10, 100, 255, 256]:
    a = i
    b = i
    print(f"  {i}: a is b = {a is b}, same object reused")
print("\nSmall negative integers (pre-allocated):")
for i in [-5, -4, -3, -2, -1]:
    a = i
    b = i
    print(f"  {i}: a is b = {a is b}, same object reused")
print("\nOutside pre-allocated range: new objects created")
for i in [-6, 257, 1000]:
    a = i
    b = i
    print(f"  {i}: a is b = {a is b}, new objects each time")
# Real-world usage examples showing why these numbers matter
print("\n=== Common usage patterns ===")
# Array indexing (0 to small positive numbers)
my_list = ['a', 'b', 'c', 'd', 'e']
for index in range(5):  # 0, 1, 2, 3, 4 all pre-allocated
    print(f"  Index {index}: {my_list[index]}")
# Reverse indexing (small negative numbers)
print(f"  Last item: {my_list[-1]}")    # -1 is pre-allocated
print(f"  Second to last: {my_list[-2]}")  # -2 is pre-allocated
# Boolean operations (0 and 1)
success = True   # internally 1 (pre-allocated)
failure = False  # internally 0 (pre-allocated)
print(f"  Success (as int): {int(success)}, Failure (as int): {int(failure)}")
# HTTP status codes (all within 0-256 range)
status_codes = [200, 404, 500, 201, 204]  # All pre-allocated
print(f"  Common HTTP codes: {status_codes}")
# ASCII values (0-127, extended to 0-255)
print(f"  ASCII 'A': {ord('A')}")  # 65, pre-allocated
print(f"  ASCII 'Z': {ord('Z')}")  # 90, pre-allocated

Conclusion

Understanding Python’s approach to object mutability, identity, and memory management is fundamental to writing correct and efficient Python code. We’ve explored how every object has an identity and type, the critical distinction between mutable objects (lists, dictionaries, sets, bytearrays) and immutable objects (numbers, strings, tuples, frozen sets, bytes), and how Python treats these categories differently. We’ve seen that assignment creates references, not copies, and that mutable objects can have multiple aliases pointing to the same data. Function arguments are passed by assignment, meaning modifications to mutable arguments affect the original object while immutable objects remain unchanged. We’ve uncovered CPython’s optimization of pre-allocating small integers from -5 to 256 (defined by NSMALLNEGINTS and NSMALLPOSINTS), and we've examined the special case of tuples and frozen sets that are immutable containers but can hold mutable contents. By mastering these concepts, you'll avoid common pitfalls with unintended side effects, write more efficient code that leverages Python's optimizations, and develop a deeper intuition for how Python programs behave at runtime.

If you found this post helpful, please share it with fellow Python developers. Have questions or insights? Connect with me on LinkedIn to continue the conversation!


메타데이터
post_id
f5a3f2883bac
slug
understanding-pythons-memory-model-mutability-identity-and-function-arguments-f5a3f2883bac
url
https://medium.com/@niyubwayoiraelie5777/understanding-pythons-memory-model-mutability-identity-and-function-arguments-f5a3f2883bac
canonical_url
https://medium.com/@niyubwayoiraelie5777/understanding-pythons-memory-model-mutability-identity-and-function-arguments-f5a3f2883bac
author_url
https://medium.com/@niyubwayoiraelie5777
status
ok
fetched_at
2026-07-13 06:23:13