Introduction
In Python, everything is an object: integers, strings, functions, classes, and even None. Each object carries overhead for reference counting and type information, and understanding this overhead is essential for writing memory-efficient Python. This lesson explores the internal structure of Python objects, built-in memory optimizations like the small integer cache, and how to measure object sizes.
Key Concepts
- PyObject: The C struct underlying every Python object, containing at minimum a reference count (
ob_refcnt) and a type pointer (ob_type). - Reference Count: An integer stored in every object that tracks how many references point to it. When it reaches zero, the object is immediately deallocated.
- Small Integer Cache: CPython pre-allocates and reuses integer objects in the range -5 to 256, so these values are singletons.
- String Interning: CPython may reuse the same string object for identical short strings, especially those that look like identifiers.
sys.getsizeof(): Returns the size in bytes of a single object (shallow size), not including the sizes of objects it references.
Real World Context
When you create a list of one million dictionaries to process API responses, each dictionary and its keys consume memory far beyond the raw data they hold. Understanding PyObject overhead helps you choose between dictionaries, named tuples, dataclasses with __slots__, or arrays depending on your memory constraints.
Deep Dive
The PyObject Structure
Every Python object in CPython is backed by a C struct. At minimum, it contains:
c// Simplified PyObject (CPython internals) typedef struct { Py_ssize_t ob_refcnt; // 8 bytes on 64-bit PyTypeObject *ob_type; // 8 bytes on 64-bit // ... object-specific data follows } PyObject;
This means the absolute minimum overhead for any Python object on a 64-bit system is 16 bytes — just for the refcount and type pointer, before any actual data.
Object Sizes in Practice
pythonimport sys # Minimum object sizes on 64-bit CPython 3.14 print(f"int(0): {sys.getsizeof(0)} bytes") # 28 bytes print(f"int(1): {sys.getsizeof(1)} bytes") # 28 bytes print(f"float: {sys.getsizeof(1.0)} bytes") # 24 bytes print(f"bool: {sys.getsizeof(True)} bytes") # 28 bytes print(f"None: {sys.getsizeof(None)} bytes") # 16 bytes print(f"empty str: {sys.getsizeof('')} bytes") # 49 bytes print(f"empty list:{sys.getsizeof([])} bytes") # 56 bytes print(f"empty dict:{sys.getsizeof({})} bytes") # 64 bytes print(f"empty set: {sys.getsizeof(set())} bytes") # 216 bytes
Note that sys.getsizeof() returns the shallow size. A list's size does not include the objects it contains.
The Small Integer Cache
CPython pre-creates integer objects for -5 through 256. Every time your code uses one of these values, Python returns the same object:
python# Cached range: -5 to 256 a = 256 b = 256 print(a is b) # True — same object from the cache a = 257 b = 257 print(a is b) # False — different objects created # This also works with negative numbers a = -5 b = -5 print(a is b) # True a = -6 b = -6 print(a is b) # False
This optimization saves enormous amounts of memory because small integers appear everywhere in Python programs (loop counters, indices, boolean-like flags).
String Interning
CPython automatically interns strings that look like identifiers (letters, digits, underscores only). You can also force interning:
pythonimport sys # Automatically interned (looks like an identifier) a = "hello" b = "hello" print(a is b) # True # Not automatically interned (contains space) a = "hello world" b = "hello world" print(a is b) # May be False # Force interning for any string a = sys.intern("hello world") b = sys.intern("hello world") print(a is b) # True — guaranteed same object
Interning is useful when you have thousands of identical string keys (e.g., JSON field names) that would otherwise waste memory as duplicate objects.
Deep Size Calculation
To measure the total memory of an object including everything it references, you need a recursive function:
pythonimport sys def deep_getsizeof(obj, seen=None): """Recursively calculate total memory of an object.""" if seen is None: seen = set() obj_id = id(obj) if obj_id in seen: return 0 seen.add(obj_id) size = sys.getsizeof(obj) if isinstance(obj, dict): size += sum( deep_getsizeof(k, seen) + deep_getsizeof(v, seen) for k, v in obj.items() ) elif isinstance(obj, (list, tuple, set, frozenset)): size += sum(deep_getsizeof(item, seen) for item in obj) return size data = {"users": [{"name": "Alice", "age": 30}]} print(f"Shallow: {sys.getsizeof(data)} bytes") print(f"Deep: {deep_getsizeof(data)} bytes")
Common Pitfalls
- Using
isto compare values instead of==: The small integer cache makesa is bwork for small numbers, but this is an implementation detail. Always use==for value comparison.isshould only be used forNonechecks. - Trusting
sys.getsizeof()for total memory:getsizeof()only reports the shallow size of a single object. A dictionary containing a million strings will report only ~64 bytes for the dict itself. - Assuming all strings are interned: Only identifier-like strings are automatically interned. If you need guaranteed interning for non-identifier strings, use
sys.intern()explicitly.
Best Practices
- Use
sys.getsizeof()with a deep helper for accurate measurements: When investigating memory usage, always account for nested objects with a recursive size calculation. - Leverage
__slots__for classes with many instances: Eliminating the per-instance__dict__saves 100+ bytes per object, which matters when you have millions of instances. - Use
sys.intern()for repeated string keys: When processing data with thousands of identical string keys (e.g., column headers, JSON fields), interning prevents duplicate string objects.
Summary
- Every Python object carries at least 16 bytes of overhead on 64-bit systems (8 bytes for reference count + 8 bytes for type pointer)
- CPython caches integer objects from -5 to 256 as singletons, saving memory for commonly used values
sys.getsizeof()reports only shallow size; use a recursive helper for total memory including nested objects- String interning automatically reuses identifier-like strings; use
sys.intern()to force interning for other strings - Understanding object overhead informs choices between dicts, named tuples,
__slots__classes, and arrays for memory-critical applications
Code Examples
import sys
def deep_getsizeof(obj, seen=None):
"""Recursively calculate total memory including nested objects."""
if seen is None:
seen = set()
obj_id = id(obj)
if obj_id in seen:
return 0
seen.add(obj_id)
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(
deep_getsizeof(k, seen) + deep_getsizeof(v, seen)
for k, v in obj.items()
)
elif isinstance(obj, (list, tuple, set, frozenset)):
size += sum(deep_getsizeof(item, seen) for item in obj)
return size
# Compare shallow vs deep sizes
users = [
{"name": "Alice", "role": "admin", "score": 95},
{"name": "Bob", "role": "user", "score": 82},
]
print(f"Shallow list size: {sys.getsizeof(users)} bytes")
print(f"Deep total size: {deep_getsizeof(users)} bytes")
print(f"\nPer-type overhead:")
print(f" int(0): {sys.getsizeof(0)} bytes")
print(f" float: {sys.getsizeof(0.0)} bytes")
print(f" empty '': {sys.getsizeof('')} bytes")
print(f" None: {sys.getsizeof(None)} bytes")