Skip to main content

Interning and Object Caching - CPython Runtime Optimizations at Engineering Depth

Reading time: ~26 minutes | Level: Foundation → Engineering

Here is a small experiment that reveals something unexpected about Python's memory model:

import sys

a = 256
b = 256
print(a is b, sys.getrefcount(a)) # True, some large number

c = 257
d = 257
print(c is d, sys.getrefcount(c)) # False, 3 (two names + getrefcount arg)

e = 257
f = 257
g = e
print(sys.getrefcount(e)) # 4 (e, f, g + getrefcount arg... wait, why f?)

Wait - f = 257 created its own separate object, so why does getrefcount(e) count it? Actually it does not: f points to a different 257 object. The count of 3 for e reflects only e, g, and the temporary reference created by passing e to getrefcount. Understanding this requires knowing exactly how CPython allocates, caches, and counts references to objects - and when it decides to reuse an existing object rather than create a new one.

These are not mere curiosities. Production engineers who debug memory leaks, optimize high-throughput servers, or investigate identity bugs in large codebases need this model. Understanding CPython's caching behavior explains why code works in one context and mysteriously fails in another, and why profiling shows millions of allocations for what seems like simple arithmetic.

What You Will Learn

  • Why CPython caches objects: the cost of object creation and how reuse eliminates it
  • The small integer cache: exact bounds, CPython source location, why those bounds
  • String interning rules: which strings are automatically interned and why
  • sys.intern(): how to force interning, the intern table's use of weak references
  • None, True, False as language-guaranteed singletons
  • Tuple and other container caching in CPython
  • The "zombie id" pitfall: why id() values can be reused after garbage collection
  • Reference counting: sys.getrefcount(), the +1 from the function argument, how Python frees objects
  • __slots__: how it eliminates per-instance __dict__ to save memory
  • When caching matters for performance and when it is invisible

Prerequisites

  • Variables, assignment, and Python types
  • Basic understanding of is vs == (see the previous lesson)
  • Familiarity with classes and methods
  • Basic command-line Python execution

Why CPython Caches Objects at All

Creating a Python object is not free. Every allocation involves:

  1. Calling the system memory allocator (malloc or CPython's custom obmalloc)
  2. Initializing the object header (type pointer, reference count)
  3. Populating the object's fields
  4. Registering the object with CPython's memory management subsystem
  5. When done, decrementing the reference count and potentially calling the destructor + memory deallocation

For large objects like dictionaries and lists, this overhead is dominated by the data. But for tiny immutable objects like the integer 1, the overhead of allocation and deallocation can be larger than the cost of any computation the object participates in.

The solution: pre-allocate the most commonly used objects once at interpreter startup, and never allocate or free them again. When code needs the integer 42, return a pointer to the pre-existing 42 object. When the reference goes away, simply decrement the reference count - since the object is permanent, the count never reaches zero and no deallocation occurs.

CPython Object Caching - Without vs With

This is the fundamental motivation for CPython's object caching.

The Small Integer Cache: Bounds and Implementation

CPython pre-allocates all integer objects from −5 to 256 inclusive. These constants are defined in Objects/longobject.c:

/* Objects/longobject.c (CPython source) */
#define NSMALLNEGINTS 5 /* cache integers from -5 */
#define NSMALLPOSINTS 257 /* cache integers up to 256 (257 exclusive) */

static PyLongObject small_ints[NSMALLNEGINTS + NSMALLPOSINTS];

The array small_ints has 262 entries (5 negatives + 257 non-negatives). At interpreter initialization, CPython populates these with the values −5 through 256. From that point on, any request for an integer in this range returns a pointer into small_ints rather than a heap allocation.

# Demonstrating the cache boundary
import sys

for n in [254, 255, 256, 257, 258]:
a = n
b = n
# In CPython, 'a is b' should be True for n <= 256
# For n > 256, 'a is b' depends on context (see note below)
refcount = sys.getrefcount(a)
print(f"n={n:4d}: a is b = {a is b}, refcount = {refcount}")
n= 254: a is b = True, refcount = <very large> # permanent cache object
n= 255: a is b = True, refcount = <very large>
n= 256: a is b = True, refcount = <very large> # last cached value
n= 257: a is b = False, refcount = 3 # fresh heap allocation
n= 258: a is b = False, refcount = 3

The reference count for cached integers is "very large" because every reference in the interpreter to that value increments the count - and since the object is permanent, the count only ever increases.

Why These Bounds?

The choice of −5 to 256 reflects empirical analysis of real Python programs:

−5 to −1: Common negative return codes and offsets (−1 is ubiquitous)
0: Zero - the most common integer in any program
1 to 9: Single-digit counting
10 to 99: Two-digit common values
100 to 127: ASCII character codes (also bytes values)
128 to 255: Upper ASCII, byte values, common powers of 2
256: One common power of 2, edge case boundary

The upper bound of 256 was chosen partly for byte value coverage (bytes values range 0–255) and partly based on profiling that showed diminishing returns beyond this range. Extending to 512 would double memory usage for the cache while covering increasingly rare values.

Compile-Time Constant Folding: The Nuance

There is an important nuance: even outside the integer cache, Python's compiler may deduplicate literal constants within a single code object.

# This function is compiled as a single code object
def example():
a = 1000
b = 1000
print(a is b) # True inside a function body - compiler reuses the constant

example()

# But at module level or interactive REPL, behavior differs
a = 1000
b = 1000
print(a is b) # False in interactive REPL - each statement is a separate code object

This is compile-time constant folding, not the integer cache. The compiler stores each unique constant value once in the code object's co_consts tuple, so multiple references to 1000 in the same function body all load the same object. This optimization does not apply across function boundaries or across separate compile() calls.

warning

This folding behavior means that a = 257; b = 257; a is b may return True in a function and False at the REPL. This unpredictability is the core reason why you must never use is for integer or string comparison in production code. The results depend on context, compilation unit, and CPython version.

String Interning: The Automatic Rules

CPython automatically interns strings that satisfy two conditions:

  1. The string consists entirely of ASCII letters, digits, and underscores (looks like a Python identifier)
  2. The string is encountered during compilation (as a source code literal or identifier)
# Rule 1: Identifier-like strings are automatically interned
a = "hello"
b = "hello"
print(a is b) # True - "hello" is a valid identifier

a = "hello_world_123"
b = "hello_world_123"
print(a is b) # True - letters, digits, underscores only

# Rule 2: Strings with other characters are NOT automatically interned
a = "hello world" # space breaks the rule
b = "hello world"
print(a is b) # False (in most contexts)

a = "hello-world" # hyphen breaks the rule
b = "hello-world"
print(a is b) # False

a = "hello!"
b = "hello!"
print(a is b) # False

The rationale for the identifier rule: attribute names, variable names, dictionary keys in code, and keyword arguments are all strings that match this pattern. CPython's attribute lookup system, dictionary implementation, and bytecode operations all benefit from being able to compare these strings by identity (a pointer comparison) rather than by value (character by character). Interning identifier-like strings converts all these comparisons from O(n) to O(1).

Compile-Time String Folding

Like integers, string literals in the same code object may be deduplicated by the compiler, even if they do not satisfy the identifier rule:

def demo():
a = "hello world" # With space - not auto-interned
b = "hello world"
print(a is b) # May be True within a single function - compiler folds

demo()

# But:
x = "hello world"
y = "hello world"
print(x is y) # False at REPL - separate code objects

And runtime-constructed strings are never automatically interned:

# Runtime concatenation - always creates new objects
a = "hel" + "lo" # NOTE: compiler may fold this at compile time if both are literals
b = "".join(["hel", "lo"]) # Runtime - definitely a new object
c = "hello"

print(a is c) # True if compiler-folded "hel"+"lo" to "hello"
print(b is c) # False - b is a runtime-created string

sys.intern(): Manual Interning

import sys

# Force strings into the intern table regardless of content
a = sys.intern("hello world with spaces")
b = sys.intern("hello world with spaces")

print(a is b) # True - both refer to the interned canonical object

# Practical: interning runtime-created strings
def intern_field_names(names):
return [sys.intern(name) for name in names]

FIELDS = intern_field_names(["user_id", "session_id", "request_path", "status_code"])

The Intern Table and Weak References

The intern table is implemented as a Python dictionary mapping string content to the canonical string object. However, the values in this table are weak references - if no normal (strong) references to an interned string exist, the garbage collector can collect it, and the intern table removes the entry.

This means sys.intern() does not unconditionally prevent garbage collection - if you intern a string and drop all references to it, the intern table entry disappears and the next sys.intern() call with the same value creates a new entry.

When to Use sys.intern()

import sys

# Use case 1: High-frequency dictionary lookup with repeated string keys
# Log parser reading millions of records with repeated field names
class LogParser:
KNOWN_FIELDS = {sys.intern(f) for f in
["timestamp", "level", "service", "message", "trace_id"]}

def parse_record(self, raw: dict) -> dict:
# By interning both stored keys and incoming keys, CPython's dict
# implementation can short-circuit to identity comparison for key lookup
return {sys.intern(k): v for k, v in raw.items()
if sys.intern(k) in self.KNOWN_FIELDS}

# Use case 2: Symbol tables in compilers/interpreters
class SymbolTable:
def __init__(self):
self._symbols: dict = {}

def intern(self, name: str) -> str:
"""Return the canonical interned version of a symbol name."""
interned = sys.intern(name)
if interned not in self._symbols:
self._symbols[interned] = {"name": interned, "refs": 0}
return interned

# Use case 3: Network protocol parsing with repeated header names
class HTTPHeaderParser:
COMMON_HEADERS = [sys.intern(h) for h in
["content-type", "authorization", "cache-control",
"x-request-id", "user-agent", "accept"]]
tip

The performance benefit of sys.intern() is most pronounced when: (1) the same string appears millions of times in dictionary key lookups, (2) the string is long enough that character-by-character comparison is measurably slower than a pointer comparison, and (3) the program runs long enough that startup interning cost amortizes. For typical CRUD web services, manual interning rarely provides meaningful gains. For high-throughput parsers, compilers, or log processors handling millions of records per second, it can matter.

Language-Guaranteed Singletons: None, True, False

Unlike the integer cache and string interning (which are CPython implementation details), the singleton nature of None, True, and False is specified by the Python Language Reference. Every Python implementation - CPython, PyPy, Jython, MicroPython - must ensure that None is the sole instance of NoneType, and that True and False are the sole instances of bool.

import sys

# None is a singleton
print(sys.getrefcount(None)) # Very large - referenced everywhere in the runtime
print(type(None)) # <class 'NoneType'>

# There is exactly one None - this is a language guarantee
a = None
b = None
print(a is b) # True - guaranteed on all Python implementations

# True and False are singletons
print(sys.getrefcount(True)) # Also very large
print(sys.getrefcount(False)) # Also very large

# bool inherits from int, True == 1 and False == 0
print(True == 1) # True
print(False == 0) # True
print(True is 1) # False - True and 1 are different objects (True is the singleton)
print(bool(1) is True) # True - bool() returns the singleton

Because these are language guarantees (not implementation details), you can safely use is None, is True, and is False in code that must run on any Python implementation. This is the entire rationale behind PEP 8's mandate to use is None rather than == None.

Tuple Caching: The Empty Tuple Singleton

CPython caches the empty tuple () as a singleton. There is exactly one empty tuple object in CPython's process:

a = ()
b = ()
c = tuple()

print(a is b) # True - same object
print(a is c) # True - tuple() also returns the cached empty tuple
print(id(a) == id(b) == id(c)) # True

import sys
print(sys.getrefcount(())) # Very large - every empty tuple reference counts this

CPython also caches small tuples in its "free list" - a pool of recently deallocated tuple structures that can be reused without going back to the system allocator. However, the reuse is of the structure (the container), not of a specific tuple's content. The empty tuple singleton is the only guarantee of identity.

# Single-element tuples: NOT guaranteed to be singletons
a = (1,)
b = (1,)
print(a is b) # May be True (compile-time folding) or False - do not rely on it

# But empty tuple: guaranteed
a = ()
b = ()
print(a is b) # Always True

Reference Counting: How CPython Tracks Object Lifetimes

CPython uses reference counting as its primary memory management mechanism. Every object has a ob_refcnt field (an integer) in its object header. CPython increments this count every time a new reference to the object is created, and decrements it every time a reference is dropped. When the count reaches zero, the object is immediately deallocated.

import sys

a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 - one from 'a', one from getrefcount's argument

b = a
print(sys.getrefcount(a)) # 3 - 'a', 'b', getrefcount argument

del b
print(sys.getrefcount(a)) # 2 - back to 'a' and getrefcount argument
note

sys.getrefcount(obj) always reports one more than the "true" count because passing obj to getrefcount creates a temporary reference (the function's parameter). So getrefcount(a) == 2 when a has one external reference.

Reference counting has one weakness: circular references. If object A holds a reference to B, and B holds a reference to A, both have a reference count of at least 1 even when nothing external refers to them. CPython's cyclic garbage collector (gc module) handles this case by periodically searching for reference cycles and breaking them.

import gc

# Reference cycle - reference counting alone cannot free these
class Node:
def __init__(self, value):
self.value = value
self.next = None

n1 = Node(1)
n2 = Node(2)
n1.next = n2
n2.next = n1 # cycle: n1 → n2 → n1

del n1, n2 # refcounts drop but don't reach zero due to cycle
# cyclic GC will eventually collect these

gc.collect() # Force a collection cycle

The Zombie ID Pitfall: id() Reuse After Garbage Collection

Because CPython recycles memory, the id() of a new object can equal the id() of a previously collected object. This is the "zombie id" problem:

a = [1, 2, 3]
addr_a = id(a)

del a # a is deleted, memory is freed (assuming no other references)

b = [4, 5, 6] # New object - may be allocated at the same address
addr_b = id(b)

print(addr_a == addr_b) # May be True - zombie id!
print(a) # NameError - a is deleted, but addr_a might equal addr_b

The practical danger:

# DANGEROUS: caching id() for identity tracking
class ObjectTracker:
def __init__(self):
self._tracked = set() # stores id() values

def register(self, obj):
self._tracked.add(id(obj)) # WRONG: id reuse after collection

def is_tracked(self, obj):
return id(obj) in self._tracked # Zombie id gives false positive!

The correct approach: use weakref to track objects without preventing garbage collection.

import weakref

class ObjectTracker:
def __init__(self):
self._tracked = weakref.WeakSet() # Correct: weakref prevents false positives

def register(self, obj):
self._tracked.add(obj)

def is_tracked(self, obj):
return obj in self._tracked # Uses object identity, not id()

A WeakSet holds weak references to objects. When an object is garbage collected, the WeakSet automatically removes its entry. There is no zombie: looking up a new object that happens to occupy the same address as a dead one will correctly return False because the new object is a different Python object, and obj in self._tracked uses identity (is) internally.

__slots__: Eliminating Per-Instance __dict__

By default, every Python object instance has a __dict__ - a dictionary that stores instance attributes. This dictionary has significant memory overhead: roughly 200–300 bytes for an empty dict in CPython, plus the key/value storage.

For classes that create many small instances (data records, points, tokens), this overhead dominates:

import sys

class PointDict:
def __init__(self, x, y):
self.x = x
self.y = y

class PointSlots:
__slots__ = ('x', 'y') # Declare all instance attributes
def __init__(self, x, y):
self.x = x
self.y = y

p_dict = PointDict(1, 2)
p_slots = PointSlots(1, 2)

print(sys.getsizeof(p_dict)) # ~48 bytes for the object...
print(p_dict.__dict__) # {'x': 1, 'y': 2}
print(sys.getsizeof(p_dict.__dict__)) # ~200+ bytes for the dict itself!

print(sys.getsizeof(p_slots)) # ~56 bytes - no separate __dict__
# p_slots.__dict__ # AttributeError - no __dict__ with __slots__

With __slots__, CPython stores instance attributes directly in the object structure as C-level fields, not in a hash table. The memory savings can be 3–5x for small objects, which matters enormously when you create millions of instances.

# Performance-sensitive example: parsing a log file into records
class LogRecord:
__slots__ = ('timestamp', 'level', 'service', 'message', 'trace_id')

def __init__(self, timestamp, level, service, message, trace_id):
self.timestamp = timestamp
self.level = level
self.service = service
self.message = message
self.trace_id = trace_id

# Creating 1 million LogRecord instances:
# Without __slots__: ~400MB+ (with __dict__ overhead)
# With __slots__: ~100MB (compact attribute storage)
warning

__slots__ has trade-offs. Slotted classes cannot have arbitrary new attributes added at runtime (no obj.new_attr = value unless new_attr is in __slots__). Subclassing slotted classes requires care - if the subclass does not define __slots__, it gets a __dict__ again, losing most of the memory benefit. Multiple inheritance with __slots__ has restrictions. Use __slots__ deliberately for performance-sensitive, high-cardinality data objects.

Putting It Together: A Performance-Oriented Design

Here is how these caching mechanisms combine in a realistic high-performance parsing scenario:

import sys
from typing import List, Dict

# Intern field names once at module load - avoids repeated interning cost
_FIELD_NAMES = tuple(
sys.intern(name) for name in
["event_type", "user_id", "session_id", "timestamp", "payload"]
)

class Event:
"""
High-cardinality event object optimized for memory and lookup speed.

Design choices:
- __slots__: eliminates __dict__, reduces instance size by ~3x
- sys.intern: field names are interned so dict key lookup uses identity
- Small field values (user_id, session counts) often hit the int cache
"""
__slots__ = _FIELD_NAMES

def __init__(self, event_type: str, user_id: int,
session_id: str, timestamp: float, payload: dict):
# sys.intern ensures event_type participates in identity-based lookup
self.event_type = sys.intern(event_type)
self.user_id = user_id # small ints are cached automatically
self.session_id = sys.intern(session_id)
self.timestamp = timestamp
self.payload = payload

def __repr__(self):
return f"Event(type={self.event_type!r}, user={self.user_id})"


def parse_events(raw_records: List[Dict]) -> List[Event]:
return [
Event(
event_type = r["event_type"],
user_id = r["user_id"],
session_id = r["session_id"],
timestamp = r["timestamp"],
payload = r.get("payload", {}),
)
for r in raw_records
]

# Usage
records = [
{"event_type": "page_view", "user_id": 42, "session_id": "abc123",
"timestamp": 1709000000.0, "payload": {"url": "/home"}},
{"event_type": "click", "user_id": 42, "session_id": "abc123",
"timestamp": 1709000001.0, "payload": {"element": "signup-btn"}},
]

events = parse_events(records)
e1, e2 = events

# event_type "page_view" and "click" are now interned
# user_id 42 is in the int cache - both e1.user_id is e2.user_id
print(e1.user_id is e2.user_id) # True - int cache (42 < 256)
print(e1.session_id is e2.session_id) # True - same interned string
print(e1.event_type is e2.event_type) # False - different event types

Interview Questions

Q1: What are the exact bounds of CPython's small integer cache and where are they defined in the source?

A: CPython caches integers from −5 to 256 inclusive. The constants are NSMALLNEGINTS = 5 and NSMALLPOSINTS = 257 in Objects/longobject.c. The total cache is an array of 262 pre-allocated PyLongObject structures created at interpreter startup. Requests for integers in this range return a pointer into this array. Requests outside this range allocate a new object on the heap. The bounds were chosen empirically to cover loop counters (0+), array indices (0+), byte values (0–255), and common small negative values (−1 for error returns, etc.).

Q2: Which strings does CPython automatically intern, and what is the criterion?

A: CPython automatically interns strings that consist entirely of ASCII letters, digits, and underscores - the set of characters valid in Python identifiers. This covers attribute names, variable names, and simple string literals. Strings with spaces, punctuation, or other non-identifier characters are not automatically interned. String literals may also be interned by compile-time constant deduplication within a single code object, regardless of content. Runtime-constructed strings (from concatenation, join(), format(), etc.) are not automatically interned. The motivation is that identifier-like strings appear as dictionary keys and attribute names billions of times in a running Python program; interning enables identity-based (O(1) pointer) comparison instead of content-based (O(n) character) comparison.

Q3: When should you use sys.intern(), and what are its limitations?

A: Use sys.intern() when: (1) your code performs millions of dictionary lookups using string keys that come from a finite known vocabulary (log field names, HTTP header names, protocol message types), (2) you're building a compiler or interpreter that maintains a symbol table, or (3) you're running a long-lived process that accumulates many repeated string values. The limitations: the intern table uses weak references, so strings with no external references will still be collected. sys.intern() cannot be used with non-string types. There is overhead for the intern table lookup itself, so interning strings used only once is counterproductive. The benefit is measurable only when the string comparison frequency is high enough to justify the interning cost.

Q4: None, True, and False are singletons. Is this a CPython optimization or a language guarantee?

A: It is a language guarantee. The Python Language Reference specifies that None is the sole instance of NoneType, and that True and False are the sole instances of bool. This means is None, is True, and is False are correct and safe on any Python implementation - CPython, PyPy, Jython, IronPython, MicroPython. In contrast, the integer cache (−5 to 256) and automatic string interning are CPython implementation details that other implementations may not replicate. PEP 8's mandate to use is None rather than == None is valid precisely because the singleton guarantee is universal.

Q5: Explain the "zombie id" problem. When does it occur, and how do you avoid it?

A: The zombie id problem occurs when code saves the id() of an object (its memory address) and then later checks whether a new object has the same id(), incorrectly concluding they are the same object. This happens because CPython reuses memory: when object A is garbage collected (reference count reaches zero), its memory is returned to the allocator, which may then allocate a new object B at the same address. id(B) == saved_id_of_A returns True even though B is a completely different object. The fix: never cache id() values for identity tracking across time. Instead, use weakref.ref(obj) or weakref.WeakSet(). A weak reference tracks the specific object without preventing collection, and automatically becomes None (or removes itself from a WeakSet) when the object is collected.

Q6: What does sys.getrefcount(obj) return, and why is it always at least 2 for any non-singleton?

A: sys.getrefcount(obj) returns the current reference count of obj - the number of references that CPython is currently tracking. It always returns at least 2 for any object passed directly to it: 1 for the caller's reference (whatever variable holds the object), and 1 for the temporary reference created by passing the object as an argument to getrefcount. When getrefcount returns, this temporary reference is released, dropping the count by 1. For singletons like None, True, False, and cached integers, the count is in the thousands or millions - every reference anywhere in the runtime increments it.

Graded Practice Challenges

Level 1 - Predict the Output

What does this code print? Explain each result.

import sys

a = 42
b = 42
c = 300
d = 300

print(a is b)
print(c is d)
print(sys.getrefcount(42))
print(sys.getrefcount(300))

x = ()
y = ()
z = tuple()
print(x is y)
print(x is z)
Show Answer
True - 42 is within the integer cache (−5 to 256)
False - 300 is outside the cache; two separate objects (usually)
<large> - 42 is a cached singleton referenced throughout the runtime
3 - 300 has: one reference 'c', one reference 'd', one from getrefcount
True - () is a cached singleton (empty tuple)
True - tuple() also returns the same empty tuple singleton

Key takeaways:

  • Integer cache boundary: 42 is in, 300 is out
  • getrefcount(42) is huge because 42 is referenced everywhere (loop counters, etc.)
  • getrefcount(300) is 3 because: c, d, and the getrefcount argument itself - but wait, c and d point to different 300 objects outside the cache. After the c = 300; d = 300 assignments, getrefcount(300) is called with the object referenced by d. At that moment, d holds one reference, the getrefcount argument holds the second, and c points to a different object with its own count of 1. So getrefcount(d) returns 2, not 3.

Correction: sys.getrefcount(300) actually returns 2 - d and the getrefcount argument. c references a different object at a different address.

  • Empty tuple () is always the same singleton: x is y is z is True

Level 2 - Debug the Code

The following object tracker is designed to remember which objects have been "processed" so they are not processed twice. It has a subtle bug related to id reuse. Identify the bug and write a corrected implementation.

class ProcessingTracker:
def __init__(self):
self._processed_ids = set()

def mark_processed(self, obj):
self._processed_ids.add(id(obj))

def is_processed(self, obj):
return id(obj) in self._processed_ids

# Usage simulation
tracker = ProcessingTracker()

task1 = {"id": 1, "action": "email"}
tracker.mark_processed(task1)

del task1 # task1 is processed and discarded

task2 = {"id": 2, "action": "sms"} # New task - but it might get the same id!

print(f"task2 processed: {tracker.is_processed(task2)}")
# Might print True even though task2 was never processed
Show Answer

The bug: ProcessingTracker stores integer id() values (memory addresses). When task1 is deleted, its memory is freed. When task2 is created, Python might allocate it at the same memory address - giving it the same id() as the dead task1. The tracker then incorrectly reports task2 as already processed.

Root cause: id() uniquely identifies an object at a point in time, not across time. After an object is collected, its id can be reused.

Corrected implementation using weakref.WeakSet:

import weakref

class ProcessingTracker:
def __init__(self):
self._processed = weakref.WeakSet()

def mark_processed(self, obj):
self._processed.add(obj)

def is_processed(self, obj):
return obj in self._processed # Uses object identity (is), not id()

# Usage:
tracker = ProcessingTracker()

task1 = {"id": 1, "action": "email"}
# Note: WeakSet cannot hold unhashable types like dicts
# Use a hashable wrapper or track by a hashable identifier

# Alternative: track by an explicit unique identifier
class ProcessingTracker2:
def __init__(self):
self._processed_keys = set() # set of hashable keys, not ids

def mark_processed(self, obj_key):
self._processed_keys.add(obj_key)

def is_processed(self, obj_key):
return obj_key in self._processed_keys

tracker2 = ProcessingTracker2()
tracker2.mark_processed(1) # track by task id, not object id
print(tracker2.is_processed(1)) # True
print(tracker2.is_processed(2)) # False - correctly not processed

The lesson: never use id() as a stable identity token across time. Use explicit application-level identifiers (task IDs, UUIDs) or weakref for object-level tracking.

Level 3 - Design Challenge

Design a SmallObjectPool class that implements object reuse for a Vector2D class (a simple 2D point). The pool should:

  1. Pre-allocate a fixed set of Vector2D objects and reuse them across calls
  2. Use __slots__ on Vector2D to minimize memory per instance
  3. Implement acquire(x, y) that returns a pre-existing Vector2D reset to new values
  4. Implement release(vec) to return the object to the pool
  5. Show that repeatedly acquiring and releasing the same logical "slot" returns the same Python object (is identity holds)
  6. Demonstrate the memory efficiency vs creating new objects each time using sys.getsizeof

Write the implementation and a test that proves object reuse.

Show Answer
import sys
from typing import Optional

class Vector2D:
"""
Memory-efficient 2D vector using __slots__.
No __dict__ overhead - just two float fields.
"""
__slots__ = ('x', 'y', '_in_use')

def __init__(self, x: float = 0.0, y: float = 0.0):
self.x = x
self.y = y
self._in_use = False

def reset(self, x: float, y: float) -> "Vector2D":
"""Reinitialize this object with new values."""
self.x = x
self.y = y
self._in_use = True
return self

def magnitude(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5

def __repr__(self) -> str:
return f"Vector2D({self.x}, {self.y}) at {id(self):#x}"


class SmallObjectPool:
"""
Object pool for Vector2D - reuses pre-allocated instances
instead of creating and garbage-collecting new ones.
"""
def __init__(self, pool_size: int = 16):
# Pre-allocate the pool - these objects live for the pool's lifetime
self._pool = [Vector2D() for _ in range(pool_size)]
self._free = list(self._pool) # all start free
self._in_use = []

def acquire(self, x: float, y: float) -> Vector2D:
"""Get a pooled Vector2D initialized to (x, y)."""
if not self._free:
raise RuntimeError(
f"Pool exhausted: all {len(self._pool)} vectors are in use"
)
vec = self._free.pop()
self._in_use.append(vec)
return vec.reset(x, y)

def release(self, vec: Vector2D) -> None:
"""Return a Vector2D to the pool."""
if vec not in self._in_use:
raise ValueError("Object not owned by this pool")
self._in_use.remove(vec)
vec._in_use = False
self._free.append(vec)

@property
def available(self) -> int:
return len(self._free)

@property
def in_use_count(self) -> int:
return len(self._in_use)


# === Test: proving object reuse ===
pool = SmallObjectPool(pool_size=4)

# Acquire a vector
v1 = pool.acquire(3.0, 4.0)
v1_id = id(v1)
print(f"Acquired: {v1}")
print(f"Magnitude: {v1.magnitude():.2f}") # 5.00
print(f"Pool available: {pool.available}") # 3

# Release it back
pool.release(v1)
print(f"Pool available after release: {pool.available}") # 4

# Acquire again - should get the same object (pool reuses LIFO)
v2 = pool.acquire(6.0, 8.0)
v2_id = id(v2)
print(f"\nAcquired again: {v2}")
print(f"Magnitude: {v2.magnitude():.2f}") # 10.00

print(f"\nSame object reused: {v1_id == v2_id}") # True - same id!
print(f"v1 is v2: {v1 is v2}") # True - same Python object

# === Memory comparison ===
class Vector2DNoSlots:
def __init__(self, x, y):
self.x = x
self.y = y

no_slots = Vector2DNoSlots(1.0, 2.0)
slotted = Vector2D(1.0, 2.0)

print(f"\nMemory: Vector2D (slots) = {sys.getsizeof(slotted)} bytes")
print(f"Memory: Vector2D (no slots) = {sys.getsizeof(no_slots)} bytes")
print(f"Memory: __dict__ alone = {sys.getsizeof(no_slots.__dict__)} bytes")
print(f"True cost without slots: {sys.getsizeof(no_slots) + sys.getsizeof(no_slots.__dict__)} bytes")

Expected output:

Acquired: Vector2D(3.0, 4.0) at 0x7f...
Magnitude: 5.00
Pool available: 3

Pool available after release: 4

Acquired again: Vector2D(6.0, 8.0) at 0x7f... (same address!)
Magnitude: 10.00

Same object reused: True
v1 is v2: True

Memory: Vector2D (slots) = 56 bytes
Memory: Vector2D (no slots) = 48 bytes
Memory: __dict__ alone = 232 bytes
True cost without slots: 280 bytes

The pool demonstrates CPython's object reuse pattern: the same Python object is reconfigured with new values rather than allocated fresh. The __slots__ reduces per-instance cost from ~280 bytes to 56 bytes - a 5x reduction critical when millions of these objects are created.

Quick Reference Cheatsheet

FeatureBehaviorGuaranteed by
Integer cacheIntegers −5 to 256 are singletonsCPython only (impl. detail)
String auto-interningIdentifier-like string literalsCPython only (impl. detail)
sys.intern(s)Forces s into intern tableCPython + PyPy
None singletonExactly one None in processPython Language Spec
True singletonExactly one True in processPython Language Spec
False singletonExactly one False in processPython Language Spec
Empty tuple ()Cached singletonCPython only (impl. detail)
id() uniquenessUnique during object lifetimePython Language Spec
id() across timeMay be reused after GCCPython behavior
ToolWhat it does
sys.getrefcount(obj)Returns reference count (always +1 for arg)
sys.intern(s)Forces string into intern table
sys.getsizeof(obj)Shallow memory size of object in bytes
gc.collect()Force a cyclic garbage collection cycle
weakref.ref(obj)Weak reference that becomes None after collection
weakref.WeakSet()Set that holds objects without preventing collection
OptimizationWhen to use
__slots__High-cardinality small data objects (>10,000 instances)
sys.intern()Millions of dict lookups with repeated string keys
Object poolingFrequent allocation/deallocation of identically-structured objects
Integer cache (natural)Automatic - no action needed for small integers

Key Takeaways

  • CPython caches objects to eliminate allocation overhead for the most common immutable values - this is a performance optimization, not a semantic guarantee
  • The integer cache covers −5 to 256 (262 values); the constants NSMALLNEGINTS and NSMALLPOSINTS are in Objects/longobject.c - production code must never rely on this range
  • String interning applies automatically to identifier-like strings (letters, digits, underscores only) encountered at compile time; sys.intern() extends this to arbitrary strings explicitly
  • None, True, and False are singletons guaranteed by the Python Language Specification - safe to use is on all Python implementations, not just CPython
  • The empty tuple () is cached as a singleton in CPython; single-element and larger tuples are not guaranteed to be identical across assignments
  • sys.getrefcount(obj) always reports one extra count because the function argument itself is a temporary reference - the "true" count is getrefcount(obj) - 1
  • The zombie id pitfall occurs when id() values are cached across garbage collection events - CPython recycles memory addresses, so a new object can have the same id() as a dead one; use weakref for safe lifetime-spanning identity tracking
  • __slots__ eliminates per-instance __dict__, reducing memory for small objects by 3–5x at the cost of losing dynamic attribute assignment and some inheritance flexibility
  • String interning with sys.intern() provides measurable performance gains only in high-frequency dictionary key lookup scenarios - for typical application code the benefit is negligible
  • Compile-time constant folding (not the integer cache) is why two identical large integer or string literals in the same function body may share identity - this is a compiler optimization that does not apply across function boundaries or REPL statements
© 2026 EngineersOfAI. All rights reserved.