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:

  1. Understand the fundamental difference between C’s primitive types and Python’s object model

  2. Inspect memory directly using Python’s ctypes module

  3. Visualize memory layouts of integers, strings, and other Python objects

  4. Know when to optimize for memory and when Python’s overhead is worthwhile

  5. Master tools like sys.getsizeof(), tracemalloc, and ctypes.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:

  1. Millions of small objects (scientific computing, data analysis)

  2. Embedded systems (limited RAM)

  3. High-performance servers (every MB counts at scale)

  4. Real-time systems (garbage collection pauses)

For these cases, Python offers solutions:

  • NumPy arrays: C-style contiguous memory

  • __slots__: Reduce per-instance overhead

  • C 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, NumPy

  • Memory profiling tools

Section 7: Advanced Topics

  • Garbage collection internals

  • Custom C extensions

  • Memory debugging

Key Takeaways#

Before we move forward, remember these principles:

  1. 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

  2. The overhead is usually worth it

    • Developer productivity matters more than bytes

    • Python is “fast enough” for most applications

    • You can optimize when needed

  3. Understanding internals makes you a better programmer

    • Know when to optimize and when not to

    • Write more efficient code naturally

    • Debug memory issues effectively

  4. 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:

  1. Why do larger integers sometimes use more bytes?

  2. Why does an empty string take more than 0 bytes?

  3. What happens to string size when you repeat it 100 times?

  4. 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: