Python Memory, GC & Profiling

🐍 Python 3.12+ 🟒 Chapter 41 of 65 πŸ“‚ Phase 8: Advanced Python πŸ“… 2026 Edition
πŸ“Œ Covered in this chapter: Shallow vs Deep Copy Β· PyObject Anatomy Β· Reference Counting Β· Generational GC (Gen 0, 1, 2) Β· timeit & cProfile Β· EAFP vs LBYL
Master the internal CPython memory architecture and performance optimization: shallow vs deep copying, PyObject structure, reference counting mechanics, cyclic garbage collection (gc module), microbenchmarks with timeit, bottlenecks with cProfile, and idiomatic Pythonic style (EAFP).
1Shallow Copy vs Deep Copy (copy module)

Understanding object copying is fundamental to preventing silent data corruption:

  • Reference Assignment (b = a): Zero copying. Both names point to the exact same memory address (id(a) == id(b)).
  • Shallow Copy (copy.copy(a) or a.copy()): Creates a new outer container, but child elements are still references to original nested objects!
  • Deep Copy (copy.deepcopy(a)): Recursively clones the outer container AND all nested child lists/dictionaries into completely independent memory objects!
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Original: matrix = [[1, 2], [3, 4]] β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Shallow Copy: Clones outer list; inner sublists sharedβ”‚ β”‚ Deep Copy: Recursively duplicates all inner lists β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
πŸ’» Example 1: Shallow Copy vs Deep Copy Demonstration
import copy

# Nested data structure:
original = [[1, 2], [3, 4]]

shallow = copy.copy(original)
deep = copy.deepcopy(original)

# Modify nested element in original list:
original[0][0] = 999

print("Original list after mutation:   ", original) # [[999, 2], [3, 4]]
print("Shallow copy (INNER CORRUPTED!):", shallow)  # [[999, 2], [3, 4]] (Shares inner sublist!)
print("Deep copy (SAFE & ISOLATED!):    ", deep)     # [[1, 2], [3, 4]] (Completely untouched!)
πŸ” When to use Deep Copy:

Always use copy.deepcopy() when cloning complex nested structures (like state dictionaries, game boards, or JSON configs) to prevent unintended side effects.

2CPython Memory Management: Reference Counting & PyObject

In standard CPython, every object is represented by a C struct called PyObject containing:

  1. ob_refcnt: The Reference Count tracking how many variables point to this object.
  2. ob_type: Pointer to the object's data type descriptor.
  3. Value payload.

Immediate Deallocation: As soon as an object's reference count drops to 0 (e.g. when a variable goes out of scope or del x is executed), CPython immediately reclaims the memory on the spot!

πŸ’» Example 2: Inspecting CPython Reference Counting with sys.getrefcount
import sys

# 1. Create a fresh list object:
sample = [1, 2, 3]

# sys.getrefcount() returns current reference count (includes temporary call ref):
print("Reference count for 'sample':", sys.getrefcount(sample) - 1) # 1

# 2. Add another reference pointer:
alias_ptr = sample
print("After creating 'alias_ptr':  ", sys.getrefcount(sample) - 1) # 2

# 3. Delete one reference:
del alias_ptr
print("After 'del alias_ptr':       ", sys.getrefcount(sample) - 1) # 1
πŸ” sys.getrefcount note:

We subtract 1 because passing sample as an argument to sys.getrefcount() temporarily increases the reference count by 1 during the function call.

3Generational Garbage Collector (Handling Cyclic References)

Reference counting alone has a major fatal flaw: Circular References (Object A points to Object B, and Object B points to Object A). Even if you delete both external variables, their reference count never drops to 0!

To resolve this, CPython includes a Cyclic Generational Garbage Collector (GC) that runs in the background:

  • Generation 0 (Youngest): Newly allocated objects. Collected very frequently.
  • Generation 1: Objects surviving Gen 0 collections.
  • Generation 2 (Oldest): Long-lived objects surviving Gen 1. Collected rarely.
πŸ’» Example 3: Circular Reference Cycles and the gc Module
import gc

# 1. Create a circular reference cycle:
class Node:
    def __init__(self, name):
        self.name = name
        self.partner = None

node1 = Node("A")
node2 = Node("B")

node1.partner = node2 # node1 points to node2
node2.partner = node1 # node2 points to node1 (CYCLE!)

# Delete external variables:
del node1
del node2

# 2. Force manual garbage collection cycle to detect and destroy isolated cycles:
unreachable_objects = gc.collect()
print(f"🧹 Garbage Collector cleaned up {unreachable_objects} circular reference objects!")
πŸ” Weak References:

To prevent circular reference leaks in trees and caches, use the standard library weakref module, which creates non-owning reference pointers that do not increment ob_refcnt.

4Performance Profiling: timeit & cProfile

Never guess where performance bottlenecks lie β€” profile and measure them!

  • timeit: Precision micro-benchmarking tool for comparing short code snippets over thousands of runs.
  • cProfile: Deterministic profiler that counts every function call, time per call, and cumulative bottlenecks across an entire application.
πŸ’» Example 4: Performance Benchmarking and Idiomatic Pythonic Coding
import timeit

# Microbenchmark: List Comprehension vs map() for string conversion:
time_comp = timeit.timeit('[str(x) for x in range(1000)]', number=10_000)
time_map = timeit.timeit('list(map(str, range(1000)))', number=10_000)

print(f"⚑ List Comprehension (10k runs): {time_comp:.4f} seconds")
print(f"⚑ map(str, ...)       (10k runs): {time_map:.4f} seconds")

# Pythonic Design Philosophy: EAFP vs LBYL
# EAFP: "Easier to Ask for Forgiveness than Permission" (try-except) -> The Python Way!
# LBYL: "Look Before You Leap" (if-else checks)
user_profile = {"name": "Balaji"}

# The Pythonic EAFP pattern:
try:
    email = user_profile["email"]
except KeyError:
    email = "default@example.com"
print("User Email (EAFP):", email)
πŸ” EAFP Advantage:

EAFP avoids redundant lookups (checking if "email" in user_profile and then accessing user_profile["email"] requires TWO hash lookups; EAFP performs only ONE!).

⚠️ Common Developer Pitfall: Assuming "del obj" Directly Frees Memory from the Operating System

"del obj" merely decrements the object's reference count and deletes the variable name from the local namespace. If other references exist, or if CPython's memory allocator (PyMalloc) pools the memory, the RAM is retained for future Python allocations rather than returned to the OS.

πŸ’» Hands-on Interactive Practice Challenge

Use timeit to benchmark whether checking membership in a set ("999 in my_set") is faster than in a list ("999 in my_list") with 1,000 numbers.

Python 3 Practice Challenge β–Ά Run in Compiler
import timeit

setup_code = """
my_list = list(range(1000))
my_set = set(range(1000))
"""

t_list = timeit.timeit('999 in my_list', setup=setup_code, number=100_000)
t_set = timeit.timeit('999 in my_set', setup=setup_code, number=100_000)

print(f"List 'in' check (100k runs): {t_list:.5f} sec")
print(f"Set 'in' check  (100k runs): {t_set:.5f} sec")
print(f"πŸš€ Set is {t_list/t_set:.1f}x FASTER due to O(1) hash lookup!")
Run This Challenge in Online Python IDE β†’
❓ Frequently Asked Questions (FAQ)

Q What is the Global Interpreter Lock (GIL) in CPython?

The GIL is a mutex lock in CPython that ensures only one native CPU thread executes Python bytecode at a time, protecting CPython's reference count memory management from race conditions.

Q What is the difference between EAFP and LBYL?

LBYL (Look Before You Leap) tests preconditions with if-statements before executing. EAFP (Easier to Ask for Forgiveness than Permission) assumes valid state and catches exceptions with try-except, which is faster for the common success path in Python.

Q Why are small integers (-5 to 256) cached in CPython memory?

CPython pre-allocates an internal global array for integers between -5 and 256 because they are used constantly for loop counters and indexing, saving millions of allocation CPU cycles.

OC
Written by Our Compiler Technical Editorial Team
Reviewed for accuracy & tested on Python 3.12+ runtime Β· Last updated August 2026