Variables in Memory — Stack, Heap, and Python's Object Model
Here is a question. What does this print?
x = [1, 2, 3] y = x y.append(4) print(x)
And this one — same code, but with integers:
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
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:
- Creates an integer object
42on the heap (or reuses a cached one — more on this later) - Creates the name
xin the current namespace (frame's local dict) - Stores a pointer from
xto that object
You can verify this with id():
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.
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):
You can measure this overhead:
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 stringCompare 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_typepointer) - 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:
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.
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:
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 immediatelyOutput:
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:
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):
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]
:::danger The Critical Distinction
b = 10→ Reassignment:bnow points to a new object.ais unaffected.b.append(4)→ Mutation: the shared object changes. Bothaandbsee 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
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:
# 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:
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 invisibleMemory 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
# 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 collectionMistake 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
| Operation | Effect on Memory | Other Names Affected? |
|---|---|---|
b = a | New name, same object | Yes — mutation through b affects a |
b = some_value | b points to different object | No — a unchanged |
a.append(x) | Object mutated in place | Yes — all aliases see change |
del a | Name removed, refcnt decremented | No — object lives if other refs exist |
id(x) | Returns memory address of object | — |
a is b | True if same object (same id()) | — |
a == b | True 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 inner | Outer: no. Inner: yes |
copy.deepcopy(x) | Fully independent clone | No |
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:
-
default_row=[]is a mutable default argument — the same list object is reused on every call toinitialize_board. -
row = default_rowis an alias, not a copy — every iteration of the outer loop appends to the same list. -
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:
- Stores features by ID
- Returns features that cannot be accidentally mutated by callers
- Tracks how many times each feature has been accessed (for eviction policy)
- 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 adeepcopy— so the caller's modifications to the original afterput()don't affect the cacheget()returns adeepcopy— 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
memoryviewor NumPy array flags, which is more complex.
- 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 whatiscompares- 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
Nonesentinel 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
