Skip to main content

Variables in Memory — Stack, Heap, and Python's Object Model

~20 min readFoundation
Python DeveloperML EngineerData Scientist

Here is a question. What does this print?

Python
x = [1, 2, 3]
y = x
y.append(4)
print(x)

And this one — same code, but with integers:

Python
a = 5
b = a
b = 6
print(a)

In the first case: [1, 2, 3, 4]. In the second: 5.

One change to y affects x. A change to b does not affect a.

If you cannot immediately explain why — you are missing Python's memory model. That model explains not just these two examples, but the behavior of every function call, every data structure, every NumPy array operation, and every argument passed to every function you will ever write.

What You Will Learn

  • What "variables are names, not boxes" actually means at the memory level
  • How stack frames and heap objects differ in CPython
  • What id() returns and why it matters
  • How reference counting manages Python object lifetimes
  • Why mutation and reassignment behave completely differently
  • How cyclic references can cause memory leaks and how Python handles them
  • What sys.getsizeof() reveals about Python object overhead
  • How this model connects to NumPy views, pandas DataFrames, and PyTorch tensors

Prerequisites

  • Comfortable running Python code
  • Aware that Python has different data types (int, list, str)
  • No prior computer science memory management experience needed

The Mental Model: Two Regions of Memory

Python memory model — Call Stack frames with name pointers, and Heap with PyObject instances

The critical insight: variables (names) live in the stack frame; objects live on the heap. A variable is not a storage box — it is a pointer to an object on the heap.

Part 1 — Names, Not Boxes

The most important mindset shift in Python:

Wrong mental model (C/Java style):

variable x → [ 42 ] (x stores the value 42)

Correct Python mental model:

name x → pointer → [ object: 42 on heap ]

When you write:

x = 42

Python does these three things:

  1. Creates an integer object 42 on the heap (or reuses a cached one — more on this later)
  2. Creates the name x in the current namespace (frame's local dict)
  3. Stores a pointer from x to that object

You can verify this with id():

Python
x = 42
print(id(x))   # e.g., 140234567890  — memory address of the object

id() returns the memory address of the object x points to. Not the address of x itself — the address of the object.

Python
x = 42
y = 42           # Same cached object for small integers
print(id(x))     # e.g., 140234567890
print(id(y))     # Same address — same object!
print(x is y)    # True — identical object in memory

a = [1, 2, 3]
b = [1, 2, 3]    # Different list objects (lists are not cached)
print(id(a))     # e.g., 140234512000
print(id(b))     # e.g., 140234513200  — different!
print(a is b)    # False — different objects, equal values

Watch: Python Names and Values — The Definitive Explanation

Part 2 — The CPython Object Structure

Every Python object on the heap has a common header structure. You can see this in CPython's source code (Include/object.h):

CPython object header hierarchy: PyObject base (ob_refcnt + ob_type) extended by PyLongObject, PyUnicodeObject, PyListObject

You can measure this overhead:

Python
import sys

print(sys.getsizeof(0))        # 24 bytes — minimal int
print(sys.getsizeof(42))       # 28 bytes — typical int
print(sys.getsizeof(10**100))  # 72 bytes — large int (more digits)
print(sys.getsizeof([]))       # 56 bytes — empty list
print(sys.getsizeof([1,2,3]))  # 88 bytes — list with 3 elements
print(sys.getsizeof(""))       # 49 bytes — empty string
print(sys.getsizeof("hello"))  # 54 bytes — 5-char string

Compare to C:

sizeof(int) = 4 bytes
sizeof(double) = 8 bytes
sizeof(char) = 1 byte

Python's object overhead exists because every Python object must support:

  • Dynamic typing (the ob_type pointer)
  • Garbage collection (the ob_refcnt)
  • Runtime introspection (type(), dir(), attribute lookup)
  • Polymorphism (any object can be stored in any variable)

:::note Why NumPy is Efficient A Python list of 1,000 integers contains 1,000 Python objects — each with its own ob_refcnt and ob_type header. A NumPy array of 1,000 integers stores 1,000 raw 64-bit integers in a contiguous C array — no per-element overhead. That is 28,000+ bytes for the list vs 8,000 bytes for the NumPy array, and the NumPy array has better cache locality. :::

Part 3 — Stack Frames and Function Calls

Every function call creates a frame on the call stack. Each frame contains:

Python frame object structure: PyFrameObject (f_code, f_locals, f_globals, f_back, value stack) pointing to PyCodeObject (co_code, co_consts, co_varnames)

When a function is called, a new frame is pushed. When it returns, the frame is popped:

import sys

def inner():
frame = sys._getframe()
print("inner frame:", frame)
print("caller frame:", frame.f_back)
print("locals:", frame.f_locals)

def outer():
x = 10
inner()

outer()

Output:

inner frame: <frame at 0x...>
caller frame: <frame at 0x...>
locals: {}

This frame structure is why local variables in one function are invisible to another — they live in different frames. And it is why recursive functions work: each recursive call gets its own frame with its own local namespace.

def recursive_example(n, depth=0):
print(" " * depth + f"Frame {depth}: n={n}")
frame = sys._getframe()
print(" " * depth + f" Frame id: {id(frame)}")
if n > 0:
recursive_example(n - 1, depth + 1)

recursive_example(3)

Output (each call is a distinct frame):

Frame 0: n=3
Frame id: 140...001
Frame 1: n=2
Frame id: 140...002
Frame 2: n=1
Frame id: 140...003
Frame 3: n=0
Frame id: 140...004

Part 4 — Reference Counting: How Python Manages Memory

CPython uses reference counting as its primary garbage collection mechanism. Every object has an ob_refcnt counter that tracks how many names (references) currently point to it.

Python
import sys

x = []           # ob_refcnt = 1 (x points to it)
y = x            # ob_refcnt = 2 (x and y point to it)
z = x            # ob_refcnt = 3 (x, y, z point to it)

print(sys.getrefcount(x))  # 4 (getrefcount itself adds a temporary reference)

del y            # ob_refcnt decreases to 3
del z            # ob_refcnt decreases to 2
# When ob_refcnt reaches 0, object is immediately deallocated

The refcount increases when:

  • A variable is assigned to the object (x = obj)
  • The object is passed as a function argument
  • The object is appended to a container

The refcount decreases when:

  • A variable is reassigned or deleted
  • The function call returns (argument's temporary reference ends)
  • The container removes the element

When refcount hits 0, CPython immediately frees the memory:

Python
class TrackedObject:
    def __init__(self, name):
        self.name = name
        print(f"  Created: {self.name}")

    def __del__(self):
        print(f"  Destroyed: {self.name}")

print("Creating objects:")
x = TrackedObject("alpha")
y = x
print(f"After y = x: two references")

print("Deleting x:")
del x          # refcount drops to 1 — NOT destroyed yet

print("Deleting y:")
del y          # refcount drops to 0 — destroyed immediately

Output:

Creating objects:
Created: alpha
After y = x: two references
Deleting x:
Deleting y:
Destroyed: alpha

The destructor runs exactly when the last reference is dropped. This deterministic cleanup is one of Python's useful properties — it is why with statements work cleanly for file handles and database connections.

Part 5 — Mutation vs Reassignment: The Core Distinction

This is the most practically important concept in Python's memory model.

Reassignment — moves the name to a different object:

Python
a = 5
b = a

print(id(a), id(b))  # Same object

b = 10               # b now points to a NEW object

print(id(a))         # a still points to 5
print(id(b))         # b now points to 10
print(a)             # 5 — unchanged
print(b)             # 10

Mutation — changes the object itself (all names see the change):

Python
a = [1, 2, 3]
b = a

print(id(a), id(b))  # Same object

b.append(4)          # Modifies the OBJECT, not the name

print(id(a))         # Same — still same object
print(id(b))         # Same — still same object
print(a)             # [1, 2, 3, 4] — a sees the change!
print(b)             # [1, 2, 3, 4]
Variable reassignment vs mutation: reassignment rebinds name to new object, mutation changes the shared object in place

:::danger The Critical Distinction

  • b = 10 → Reassignment: b now points to a new object. a is unaffected.
  • b.append(4) → Mutation: the shared object changes. Both a and b see the change.

This distinction determines whether other variables are affected. :::

Part 6 — Mutable vs Immutable Objects

Python objects are either mutable (can be changed in place) or immutable (cannot):

Immutable objects:
int, float, complex, bool, str, bytes, tuple, frozenset, None

Mutable objects:
list, dict, set, bytearray, most user-defined class instances

Immutable objects cannot be mutated — only reassigned:

s = "hello"
# s[0] = "H" # TypeError: 'str' object does not support item assignment

# You must create a new object:
s = "H" + s[1:] # New string object, s now points to it
print(s) # "Hello"

This has an important consequence for safety:

# Sharing immutable objects is always safe
a = 42
b = a
# No matter what b "does", a's value cannot change
# (integers have no mutating methods)

# Sharing mutable objects requires care
x = {"key": "value"}
y = x
y["new_key"] = "new_value" # Mutates the shared dict
print(x) # {"key": "value", "new_key": "new_value"} — x is affected

Watch: Python Memory Management and Reference Counting

Part 7 — Cyclic References and the Cyclic Garbage Collector

Reference counting has a fundamental weakness: it cannot handle cycles.

import gc

# Create a cycle: a references b, b references a
a = []
b = [a]
a.append(b)

# Now: a has refcount ≥ 1 (b points to it)
# b has refcount ≥ 1 (a points to it)

del a, del b # Drop external references
# BUT: a's refcount is still 1 (b still points to it)
# b's refcount is still 1 (a still points to it)
# Reference counting cannot free either — they keep each other alive
Cyclic reference memory leak — list a and list b pointing to each other, both with refcnt=1 after external references are deleted

CPython solves this with a cyclic garbage collector that runs periodically, detects these cycles, and cleans them up:

import gc

# Force a GC collection (usually not needed)
collected = gc.collect()
print(f"Garbage collected: {collected} objects")

# Check GC stats
print(gc.get_stats())

# Objects with __del__ methods can complicate cycle collection
# (Python 3.4+ handles this better than older versions)

The cyclic GC uses a generational collection algorithm — objects that survive multiple collections are assumed to be long-lived and are checked less frequently.

:::tip When Does This Matter? For most Python programs, you never need to think about this. But if you build large data structures with back-references (trees where nodes point to parents, doubly-linked lists, observer patterns), you may create cycles. Using weakref for back-references avoids creating reference cycles. :::

Part 8 — Small Integer Caching and String Interning

CPython caches small integers from -5 to 256:

Python
# Small integers are cached — same object
a = 100
b = 100
print(a is b)    # True — same cached object

# Large integers are NOT cached — different objects
a = 1000
b = 1000
print(a is b)    # False — different objects (usually)

# Proof: ids differ for large integers
x = 257
y = 257
print(id(x) == id(y))  # Often False

Why? Small integers (-5 to 256) are so commonly used (loop counters, indices, boolean flags) that caching them avoids millions of allocations.

CPython also interns strings that look like identifiers:

a = "hello"
b = "hello"
print(a is b) # True — CPython interns simple strings

a = "hello world" # Contains a space
b = "hello world"
print(a is b) # May be False — spaces prevent automatic interning

# You can force interning:
import sys
a = sys.intern("hello world")
b = sys.intern("hello world")
print(a is b) # True — explicitly interned

:::warning Never Rely on Interning Integer caching and string interning are CPython implementation details, not Python language guarantees. PyPy, Jython, and other implementations may not behave the same way. Never use is for value comparison — always use ==. :::

Part 9 — Pass-by-Object-Reference in Function Calls

Python's function argument passing is neither pass-by-value (C) nor pass-by-reference (C++ references). It is pass-by-object-reference — the parameter is bound to the same object as the argument.

def demonstrate(lst, num):
print(f" id(lst) inside: {id(lst)}")
print(f" id(num) inside: {id(num)}")

my_list = [1, 2, 3]
my_num = 42

print(f"id(my_list): {id(my_list)}")
print(f"id(my_num): {id(my_num)}")
demonstrate(my_list, my_num)

Output (ids match — same objects):

id(my_list): 140234567000
id(my_num): 140234567890
id(lst) inside: 140234567000 ← same object
id(num) inside: 140234567890 ← same object

The consequence:

Python
def mutate(lst):
    lst.append(99)      # Mutates shared object — VISIBLE to caller

def rebind(lst):
    lst = [100, 200]    # Rebinds local name — NOT visible to caller

original = [1, 2, 3]

mutate(original)
print(original)   # [1, 2, 3, 99] — mutation is visible

rebind(original)
print(original)   # [1, 2, 3, 99] — rebinding is invisible

Memory diagram for function calls:

Before mutate(original) is called:
caller namespace: original ──────► [1, 2, 3]

Inside mutate:
local namespace: lst ─────────────► [1, 2, 3] (same object)
lst.append(99) → [1, 2, 3, 99] (object modified in place)

After mutate returns:
caller namespace: original ──────► [1, 2, 3, 99] (sees change)

---

Before rebind(original) is called:
caller namespace: original ──────► [1, 2, 3, 99]

Inside rebind:
local namespace: lst ─────────────► [1, 2, 3, 99] (same object)
lst = [100, 200] → lst now ──────► [100, 200] (new object, local only)

After rebind returns:
caller namespace: original ──────► [1, 2, 3, 99] (unchanged)
[100, 200] has refcnt=0, freed immediately

AI/ML Real-World Connection

NumPy Views vs Copies

NumPy uses Python's reference model — but adds its own layer:

import numpy as np

arr = np.array([1, 2, 3, 4, 5])

# Slicing creates a VIEW (shares memory with original)
view = arr[1:4]
print(view.base is arr) # True — view points into arr's data

view[0] = 99
print(arr) # [ 1 99 3 4 5] — original is modified!

# .copy() creates an independent array
copy = arr[1:4].copy()
print(copy.base) # None — no base, owns its own data

copy[0] = 0
print(arr) # [ 1 99 3 4 5] — unchanged

This is the same mutation-vs-reassignment principle, but for array data. NumPy slice views are memory aliases — they point to the same underlying C array.

Real production bug this causes:

# Preprocessing pipeline bug
def normalize(data):
normalized = data / data.max() # Creates new array — SAFE
return normalized

def normalize_inplace(data):
data /= data.max() # Modifies in place — DANGEROUS
return data

training_data = np.random.randn(1000, 784)
val_batch = training_data[:100] # This is a VIEW

# Bug: normalize_inplace(val_batch) would modify training_data!
safe = normalize(val_batch) # Returns new array — original untouched

PyTorch Gradient Computation

import torch

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

# tensor.view() returns a view — shares storage
y = x.view(3, 1)
print(y.storage().data_ptr() == x.storage().data_ptr()) # True — same data

# Gradients flow through views correctly
loss = y.sum()
loss.backward()
print(x.grad) # tensor([1., 1., 1.])

# tensor.clone() creates a true copy
z = x.clone()
print(z.storage().data_ptr() == x.storage().data_ptr()) # False — different data

Understanding Python's memory model is the prerequisite to understanding why PyTorch distinguishes .view(), .reshape(), .clone(), and .detach().

pandas Column Access

import pandas as pd

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

# Column access may return a view or a copy depending on situation
col = df["a"]
col[0] = 99
# May raise SettingWithCopyWarning — ambiguous whether this modifies df

# Safe pattern: explicit copy or .loc
df.loc[0, "a"] = 99 # Definitely modifies df
safe_col = df["a"].copy() # Definitely independent
safe_col[0] = 0 # Does NOT modify df

The SettingWithCopyWarning in pandas is a direct consequence of Python's reference model applied to DataFrame operations.

Common Mistakes in Production Code

Mistake 1: Aliasing Instead of Copying in Data Pipelines

# Bug: sort mutates the original
def process_data(records):
sorted_records = records # ALIAS, not copy
sorted_records.sort() # Sorts the original!
return sorted_records

original = [5, 2, 8, 1]
result = process_data(original)
print(original) # [1, 2, 5, 8] — original destroyed!

# Fix: use sorted() which creates a new list
def process_data_safe(records):
return sorted(records) # New sorted list, original unchanged

Mistake 2: Mutable Default Argument

Python
# Bug: the list is created once at function definition
def add_item(item, collection=[]):
    collection.append(item)
    return collection

print(add_item("apple"))    # ['apple']
print(add_item("banana"))   # ['apple', 'banana'] ← Bug! Same list!
print(add_item("cherry"))   # ['apple', 'banana', 'cherry'] ← Wrong!

# Fix: use None sentinel
def add_item_safe(item, collection=None):
    if collection is None:
        collection = []         # New list on every call
    collection.append(item)
    return collection

Mistake 3: Shared Mutable Class Attributes

# Bug: class attribute is shared across all instances
class Config:
settings = {} # This is a CLASS attribute — shared!

a = Config()
b = Config()
a.settings["key"] = "value"
print(b.settings) # {"key": "value"} — b is affected!

# Fix: use instance attributes
class ConfigSafe:
def __init__(self):
self.settings = {} # Instance attribute — independent per object

Interview Questions

Q1: What is the difference between the stack and the heap in Python?

Answer: In CPython, the call stack contains stack frames — one per active function call. Each frame holds the local variable namespace (a dict mapping name → object pointer), references to global and built-in namespaces, and the bytecode position. The heap is where all Python objects live — integers, strings, lists, function objects, class instances — everything. Variables in frames store pointers to heap objects. When a function returns, its frame is destroyed, but heap objects it referenced persist as long as their reference count is nonzero.

Q2: What does id() return and why is it useful?

Answer: id() returns the memory address of the Python object — not the address of the variable. It is useful for verifying object identity: if id(a) == id(b), then a and b point to the same object. This is exactly what the is operator checks. Use id() for debugging to understand aliasing; use is in production code for identity checks (None, singletons). Note: CPython reuses memory addresses after objects are freed, so id() values are only meaningful while the object is alive.

Q3: Explain Python's reference counting mechanism.

Answer: Every Python object has an ob_refcnt field that counts how many references point to it. When a variable is bound to an object, ob_refcnt increases. When a variable is rebound or deleted, ob_refcnt decreases. When ob_refcnt reaches 0, CPython immediately frees the object. This gives deterministic cleanup — __del__ runs exactly when the last reference drops. The limitation: reference counting cannot handle cycles (a→b→a). CPython solves this with a periodic cyclic garbage collector (gc module).

Q4: What is the difference between mutation and reassignment?

Answer: Reassignment (b = new_value) changes which object the name b points to — the previous object is unaffected, and other names pointing to it still see the old value. Mutation (b.append(x) or b["key"] = val) changes the object itself — all names pointing to that object see the change immediately. This distinction determines whether a function that receives an argument can modify what the caller sees. Mutation on a mutable argument is visible to the caller; rebinding the parameter name is not.

Q5: Why does Python cache small integers?

Answer: CPython pre-creates integer objects for values -5 through 256 and reuses them forever. These small integers are used extremely frequently (loop counters, indices, boolean values). Caching avoids repeated heap allocations and deallocations for these ubiquitous values. The consequence: a is b is True for integers in this range even if assigned separately — but only in CPython, not necessarily other implementations. This is why you must never use is for integer comparison — it works accidentally for small integers but fails for larger ones.

Q6: How does function argument passing work in Python?

Answer: Python uses pass-by-object-reference. When you call func(x), the parameter name inside the function is bound to the same object as the caller's argument — no copying occurs. If the function mutates the object (e.g., lst.append()), the caller sees the change because they share the same object. If the function rebinds the parameter name (e.g., lst = []), the caller does not see the change because rebinding only affects the local namespace. This is the key distinction: mutation affects the shared object; rebinding affects only the local name.

Q7: What is a cyclic reference and how does Python handle it?

Answer: A cyclic reference occurs when a set of objects form a reference chain back to themselves — e.g., object A contains a reference to object B, and B contains a reference back to A. Reference counting cannot free such objects because their refcounts never reach 0 (each keeps the other alive). CPython handles this with a supplementary cyclic garbage collector that periodically scans for unreachable cycles. You can invoke it manually with gc.collect(). To avoid creating cycles, use weakref.ref() for back-references (parent pointers, observer registrations) — weak references do not increment ob_refcnt.

Quick Reference Cheatsheet

OperationEffect on MemoryOther Names Affected?
b = aNew name, same objectYes — mutation through b affects a
b = some_valueb points to different objectNo — a unchanged
a.append(x)Object mutated in placeYes — all aliases see change
del aName removed, refcnt decrementedNo — object lives if other refs exist
id(x)Returns memory address of object
a is bTrue if same object (same id())
a == bTrue if equal values (calls __eq__)
sys.getrefcount(x)Returns reference count (+1 for the call itself)
gc.collect()Force cyclic garbage collection
copy.copy(x)New outer container, shared innerOuter: no. Inner: yes
copy.deepcopy(x)Fully independent cloneNo

Graded Practice Challenges

Level 1 — Predict the Output

a = [1, 2, 3]
b = a
b = b + [4]
print(a)
Show Answer

Output: [1, 2, 3]

b + [4] creates a new list object and assigns it to b. The original list [1, 2, 3] that a and the old b pointed to is untouched.

Compare with: b += [4] which calls list.__iadd__(), mutating in place — a would then show [1, 2, 3, 4].

Level 1 — Predict the Output

x = {"a": 1}
y = x
y["b"] = 2
print(len(x))
Show Answer

Output: 2

y = x makes y an alias for the same dict object. Adding "b" to y mutates the shared dict. x and y both point to the same dict, now {"a": 1, "b": 2}, so len(x) is 2.

Level 2 — Debug the Code

Find the bug and explain why it occurs:

def initialize_board(rows=8, cols=8, default_row=[]):
board = []
for i in range(rows):
row = default_row
for j in range(cols):
row.append(0)
board.append(row)
return board

board = initialize_board()
print(board[0][0]) # What does this print?
print(len(board[0])) # And this?
Show Answer

Bugs:

  1. default_row=[] is a mutable default argument — the same list object is reused on every call to initialize_board.

  2. row = default_row is an alias, not a copy — every iteration of the outer loop appends to the same list.

  3. board.append(row) appends the same list object 8 times — all rows are the same object.

After running: board[0][0] would be 0 (the first element), but len(board[0]) would be 64 — every row append (8 rows × 8 columns) went into the single shared list. And all 8 rows in board are the same object.

Fix:

def initialize_board(rows=8, cols=8):
return [[0] * cols for _ in range(rows)]
# List comprehension creates a new inner list for each row

Level 3 — Design Challenge

You are building a caching layer for a machine learning feature store. The cache stores tensors (as Python lists for this exercise). Multiple pipeline workers access the cache concurrently (simplified: calling functions on the cache object).

Design a FeatureCache class that:

  1. Stores features by ID
  2. Returns features that cannot be accidentally mutated by callers
  3. Tracks how many times each feature has been accessed (for eviction policy)
  4. Handles the case where a feature is not found

Demonstrate that your implementation is safe against accidental mutation.

Show Reference Solution
import copy
from typing import Optional

class FeatureCache:
"""
A mutation-safe feature cache.

Returns copies of stored features to prevent callers
from accidentally mutating cached data.
"""

def __init__(self):
self._store: dict = {} # ID → feature data
self._access_count: dict = {} # ID → int

def put(self, feature_id: str, feature: list) -> None:
"""Store a deep copy to prevent the caller's mutations from affecting cache."""
self._store[feature_id] = copy.deepcopy(feature)
self._access_count[feature_id] = 0

def get(self, feature_id: str) -> Optional[list]:
"""Return a copy — callers cannot mutate the cached version."""
if feature_id not in self._store:
return None
self._access_count[feature_id] += 1
return copy.deepcopy(self._store[feature_id])

def get_access_count(self, feature_id: str) -> int:
return self._access_count.get(feature_id, 0)

def __len__(self):
return len(self._store)


# Demonstrate mutation safety
cache = FeatureCache()

original_feature = [1.0, 2.0, 3.0, 4.0]
cache.put("user_123", original_feature)

# Worker 1 gets the feature and modifies it
feature_w1 = cache.get("user_123")
feature_w1.append(99.0) # Worker 1 modifies their copy

# Worker 2 gets the feature — should see the original, not worker 1's modification
feature_w2 = cache.get("user_123")

print(feature_w1) # [1.0, 2.0, 3.0, 4.0, 99.0] — worker 1's modified copy
print(feature_w2) # [1.0, 2.0, 3.0, 4.0] — unaffected original

# Also verify the cache's stored version is unaffected
internal_copy = cache.get("user_123")
print(internal_copy) # [1.0, 2.0, 3.0, 4.0] — cache is intact

print(f"Access count: {cache.get_access_count('user_123')}") # 3

Design decisions:

  • put() stores a deepcopy — so the caller's modifications to the original after put() don't affect the cache
  • get() returns a deepcopy — so callers can modify their copy freely without corrupting the cache
  • The trade-off: extra memory and copy time. For large tensors, consider returning a read-only view instead — but that requires memoryview or NumPy array flags, which is more complex.
Key Takeaways
  • Python variables are names (pointers) bound to objects — not storage containers
  • All Python objects live on the heap; variable names live in stack frame namespaces
  • id() returns the object's memory address — this is what is compares
  • Reference counting (ob_refcnt) manages object lifetimes — objects are freed when refcount reaches 0
  • Cyclic references defeat reference counting — CPython has a periodic cyclic GC for cleanup
  • Mutation changes the shared object — all references see the change
  • Reassignment changes which object a name points to — other names unaffected
  • Mutable default arguments are evaluated once at function definition — use None sentinel instead
  • Python's argument passing is pass-by-object-reference — mutation is visible, rebinding is not
  • NumPy views are aliases into the same C array — mutations propagate; use .copy() for independence
  • PyTorch .view() shares tensor storage; .clone() creates an independent copy
  • Understanding this model is the prerequisite for understanding pandas' SettingWithCopyWarning
© 2026 EngineersOfAI. All rights reserved.