Introduction: Why Python Objects Are “Heavier”#
The Mystery of 28 Bytes#
Let’s start with a simple question that puzzles many Python developers:
import sys
import ctypes
# Python integer
py_int: int = 5
print(f"Python int(5): {sys.getsizeof(py_int)} bytes")
# C integer
c_int: ctypes.c_int = ctypes.c_int(5)
print(f"C int(5): {ctypes.sizeof(c_int)} bytes")
# The mystery:
print(f"\nMemory overhead: {sys.getsizeof(py_int) - ctypes.sizeof(c_int)} bytes")
print(f"Overhead ratio: {sys.getsizeof(py_int) / ctypes.sizeof(c_int):.1f}x")
Python int(5): 28 bytes
C int(5): 4 bytes
Memory overhead: 24 bytes
Overhead ratio: 7.0x
Why does Python need 28 bytes to store the number 5, when C only needs 4 bytes?
This tutorial series will answer this question in depth, showing you exactly how Python and C differ in their approach to memory management.
What You’ll Learn#
By the end of this tutorial series, you will:
Understand the fundamental difference between C’s primitive types and Python’s object model
Inspect memory directly using Python’s
ctypesmoduleVisualize memory layouts of integers, strings, and other Python objects
Know when to optimize for memory and when Python’s overhead is worthwhile
Master tools like
sys.getsizeof(),tracemalloc, andctypes.Structure
Who Is This Tutorial For?#
This tutorial is designed for:
Newcomers to systems programming who want to understand how computers actually store data
Experienced Python developers curious about CPython internals
Performance engineers optimizing memory-intensive applications
C programmers transitioning to Python and wondering about the differences
Anyone who’s ever wondered “why is Python slower than C?”
Prerequisites#
To get the most out of this tutorial, you should have:
Basic Python knowledge (functions, classes, type hints)
Curiosity about how computers work (no C knowledge required!)
Python 3.12+ installed
We’ll teach you the C concepts you need along the way.
The Core Trade-off: Memory vs Convenience#
Python’s “extra” memory usage isn’t waste—it’s an intentional design choice. Let’s see what Python gives you in exchange:
from __future__ import annotations
# In Python: integers can be arbitrarily large
huge_number: int = 10 ** 100
print(f"10^100 = {huge_number}")
print(f"Size: {sys.getsizeof(huge_number)} bytes")
print(f"This would overflow in C!")
# In Python: you get type information
value: int = 42
print(f"\nType: {type(value).__name__}")
print(f"Methods: {len(dir(value))} available")
# In Python: automatic memory management
numbers: list[int] = [i for i in range(100)]
# No need to manually free memory!
del numbers # Python handles cleanup
10^100 = 10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Size: 72 bytes
This would overflow in C!
Type: int
Methods: 74 available
In C, none of this is automatic:
Integer overflow is silent and dangerous
You have to track types yourself
You must manually allocate and free memory
Mistakes lead to crashes and security vulnerabilities
Visualizing the Difference#
Let’s visualize what’s happening at the memory level:
from omnivault.utils.memory.inspector import MemoryInspector
# Inspect a Python integer
inspector = MemoryInspector(5)
print(inspector.summary())
print("\nFirst 32 bytes of memory:")
print(inspector.dump_hex(32))
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
Cell In[3], line 1
----> 1 from omnivault.utils.memory.inspector import MemoryInspector
2
3 # Inspect a Python integer
4 inspector = MemoryInspector(5)
ModuleNotFoundError: No module named 'omnivault.utils.memory'
C Integer Memory Layout#
C int (4 bytes):
┌─────────────┐
│ 05 00 00 00│ Just the value (little-endian)
└─────────────┘
Python Integer Memory Layout#
Python int (28 bytes on 64-bit):
┌─────────────────┐ ← Offset 0
│ ob_refcnt (8B) │ Reference count for GC
├─────────────────┤ ← Offset 8
│ ob_type (8B) │ Pointer to type object (int)
├─────────────────┤ ← Offset 16
│ ob_size (8B) │ Number of "digits" in integer
├─────────────────┤ ← Offset 24
│ ob_digit (4B) │ Actual value (5)
└─────────────────┘ ← End (28 bytes total)
Notice the difference:
C: Just the raw value
Python: Type metadata + reference counting + size tracking + value
The “Everything Is An Object” Philosophy#
In Python, everything is an object. This means:
# Even the number 5 is a full-fledged object
num: int = 5
print(f"Value: {num}")
print(f"Type: {type(num)}")
print(f"ID (memory address): {id(num):#x}")
print(f"Methods available: {', '.join([m for m in dir(num) if not m.startswith('_')][:5])}")
# You can call methods on it!
print(f"\n5 as binary: {num.bit_length()} bits needed")
print(f"5 to bytes: {num.to_bytes(1, 'big')}")
In C, the number 5 is just a pattern of bits. It has no “methods” or “type” at runtime.
Memory Is a Trade-off, Not a Problem#
Before we dive deeper, let’s establish an important principle:
Python’s memory overhead is usually not a problem.
Modern computers have gigabytes of RAM. For most applications, spending an extra 24 bytes per integer is completely insignificant.
When Memory Overhead Matters#
Memory becomes important when you’re working with:
Millions of small objects (scientific computing, data analysis)
Embedded systems (limited RAM)
High-performance servers (every MB counts at scale)
Real-time systems (garbage collection pauses)
For these cases, Python offers solutions:
NumPy arrays: C-style contiguous memory
__slots__: Reduce per-instance overheadC extensions: Drop down to C for critical paths
PyPy: Alternative Python implementation with better memory usage
When Memory Overhead Doesn’t Matter#
For most applications:
Web servers (handling thousands of requests)
Data pipelines (processing files)
Automation scripts
Machine learning (bottleneck is usually GPU/computation)
The developer productivity you gain from Python far outweighs the memory cost.
Comparing Memory Usage#
Let’s see a practical comparison:
from omnivault.utils.memory.profiler import track_memory
# Create 100,000 integers in Python
with track_memory() as snapshot:
python_ints: list[int] = list(range(100_000))
print(f"100,000 Python ints: {snapshot.peak_mb:.2f} MB")
# Compare with ctypes array (C-style)
with track_memory() as snapshot:
c_ints = (ctypes.c_int * 100_000)(*range(100_000))
print(f"100,000 C ints (ctypes): {snapshot.peak_mb:.2f} MB")
print(f"\nMemory ratio: {snapshot.peak_mb / 0.4:.1f}x more for Python")
The Journey Ahead#
This tutorial series will take you on a journey through memory internals:
Section 1: C Memory Basics
Stack vs heap allocation
Primitive types and structs
Memory alignment and padding
Section 2: Python Object Model
PyObject structure
Reference counting
Type system
Section 3: Integer Internals
PyLongObject structure
Arbitrary precision arithmetic
Integer interning (-5 to 256)
Section 4: String Internals
PyUnicodeObject structure
Compact ASCII optimization
String interning
Section 5: ctypes Inspection
Hands-on memory probing
Building inspection tools
Visualizing object layouts
Section 6: Optimization Techniques
When and how to optimize
__slots__, generators, NumPyMemory profiling tools
Section 7: Advanced Topics
Garbage collection internals
Custom C extensions
Memory debugging
Key Takeaways#
Before we move forward, remember these principles:
Python uses more memory per object, but that’s by design
Reference counting enables automatic memory management
Type information enables dynamic typing
Rich object model enables powerful features
The overhead is usually worth it
Developer productivity matters more than bytes
Python is “fast enough” for most applications
You can optimize when needed
Understanding internals makes you a better programmer
Know when to optimize and when not to
Write more efficient code naturally
Debug memory issues effectively
Memory management is a spectrum
C: Manual, dangerous, maximum control
Python: Automatic, safe, convenient
NumPy/C extensions: Best of both worlds
Exercise: Your First Memory Investigation#
Before moving to the next section, try this exercise:
from omnivault.utils.memory.inspector import MemoryInspector
# TODO: Investigate these objects and compare their sizes
objects_to_investigate: list[tuple[str, object]] = [
("Small int", 0),
("Medium int", 1000),
("Large int", 10**20),
("Empty string", ""),
("Short string", "hello"),
("Long string", "hello" * 100),
("Empty list", []),
("Small list", [1, 2, 3]),
]
print("Object Memory Investigation")
print("=" * 70)
print(f"{'Description':<20} {'Type':<15} {'Size (bytes)':<15} {'Refcount'}")
print("-" * 70)
for description, obj in objects_to_investigate:
inspector = MemoryInspector(obj)
print(f"{description:<20} {inspector.type_name:<15} {inspector.size:<15} {inspector.refcount}")
Questions to ponder:
Why do larger integers sometimes use more bytes?
Why does an empty string take more than 0 bytes?
What happens to string size when you repeat it 100 times?
Why does an empty list take more space than you might expect?
We’ll answer all these questions in the upcoming sections!
Next Steps#
Now that you understand why Python uses more memory, it’s time to understand how memory works at a fundamental level.
Continue to Section 1: C Memory Basics →
In the next section, we’ll explore how C manages memory, laying the foundation for understanding Python’s approach.
Additional Resources#
Tutorial Series Navigation:
Current: 00 - Introduction
Next: 01 - C Memory Basics