Skip to content
Path to Engineer
All lessons
PythonPython internals13 min read

Python memory, __slots__ and interning

Where the bytes go: per-object overhead, what __slots__ saves, string interning, and the small-integer cache that surprises people.

Day 28 of the 488-day pathway. Published in full — nothing held back.

Memory — object overhead, __slots__, interning, the small-integer cache

The sentence to own: a Python object costs far more than the data it holds, and the biggest single line item is that every instance carries its own dictionary.

This is the day that turns Day 001's list vs array.array measurement into an explanation, and it produces the two facts that make is behave strangely (Day 029).


Part 1 — What things actually cost

import sys
sys.getsizeof(0)          #   24    — header only, zero digits
sys.getsizeof(1)          #   28    — 24 + one 4-byte digit
sys.getsizeof(2**64)      # 36
sys.getsizeof("")         # 49
sys.getsizeof("a")        # 50      —   +1 per ASCII char (compact representation)
sys.getsizeof("é")        #   75    — a non-Latin-1 char switches to a wider representation
sys.getsizeof([])         # 56
sys.getsizeof([1,2,3])    #   80    — ⚠️    56 + 3 POINTERS. NOT the ints.
sys.getsizeof({})         # 64
sys.getsizeof(set())      # 216
   THE MEASUREMENT THAT EXPLAINS DAY 001:
    a list of 1,000,000 small ints:
      the list itself      = 8 MB of POINTERS
    + 1,000,000 int objects × 28 bytes = 28 MB
    ⇒    ~36 MB, scattered across the heap ⇒ every access is a POINTER CHASE ⇒ cache misses

    array.array("q", ...) =    8 MB, CONTIGUOUS, no objects at all
    numpy array           =   the same, plus vectorised C loops
  ⇒    THAT is "why is NumPy fast": it is not a faster Python. It is NO Python.

sys.getsizeof is shallow — it never counts what a container points at. To measure a real structure, walk it recursively or use pympler.


Part 2 — __slots__

class Point:                       #   the default
    def __init__(self, x, y):
        self.x, self.y = x, y
# ⇒    every instance carries a __dict__ — a whole hash table — to hold two numbers.

class SlottedPoint:
    __slots__ = ("x", "y")         #    no __dict__; fixed C-array storage, like a struct
    def __init__(self, x, y):
        self.x, self.y = x, y
Normal __slots__
Per instance ~56 B + a __dict__ (~104 B, more once populated) ~56 B total
Attribute access dict lookup a fixed offset — faster
New attributes at runtime yes AttributeError
weakref works needs "__weakref__" in the slots
Inheritance free every class in the chain must define slots or the __dict__ returns
   THE NUMBER THAT DECIDES IT: ~50–60% memory saved, per instance.
    1,000,000 Point objects:   ~160 MB → ~56 MB.
  ⇒    WHEN TO USE IT: when you have MANY instances of a small, fixed-shape class.
       ⇒ graph nodes, parsed records, market ticks, tree nodes in a DSA problem
  ⇒    WHEN NOT TO: everything else. It is a memory optimisation with real ergonomic cost,
       and adding it to a class that has ten instances is noise.

The modern shortcuts: @dataclass(slots=True) (Python 3.10+) gives you both, and Pydantic and attrs have equivalents. NamedTuple is even smaller — it is a tuple, with no per-instance storage at all — when you need an immutable record.


Part 3 — Interning and caching — the source of is weirdness

a = 256;  b = 256;  a is b       #    True
a = 257;  b = 257;  a is b       #    False  (at the REPL — see below)
   THE SMALL-INTEGER CACHE: CPython pre-creates every int from -5 to 256 at startup
    and reuses those objects forever.
    ⇒   why those? they cover loop counters, list indices, small counts — the overwhelming
      majority of integers a program actually creates.
    ⇒    SO `is` "works" for small ints and silently stops at 257. This is why you never
         compare numbers with `is`.
# ⚠️    AND IT GETS MORE CONFUSING — compile-time constant folding:
def f():
    a = 257
    b = 257
    return a is b
f()          #    True! — both 257s are the SAME constant in the function's code object
# ⇒    the answer depends on whether the values are in the same compilation unit.
# ⇒    WHICH IS THE REAL LESSON: identity of equal immutables is AN IMPLEMENTATION DETAIL.
#   STRING INTERNING — the same idea, for strings:
"hello" is "hello"                 #   True — compile-time constants are interned
("hel" + "lo") is "hello"          #   True — constant-folded by the compiler
a = "hel"; (a + "lo") is "hello"   #    False — built at RUNTIME
import sys
sys.intern(a + "lo") is "hello"    #   True — explicit interning
   WHICH STRINGS ARE AUTO-INTERNED: identifier-like literals (letters, digits, underscore)
    ⇒ because Python interns every NAME anyway — attribute lookup is a dict lookup by string,
      and interning makes those comparisons a POINTER COMPARISON instead of a character scan.
  ⇒    THE REAL USE OF sys.intern: millions of repeated strings from parsing —
       log levels, column names, enum-like tokens. It collapses them to one object each.

The rule that comes out of all of it, and it is the practical one: use is only for None, True, False, and genuine identity checks. Never for numbers or strings. Python 3.8+ even emits a SyntaxWarning when it catches you writing x is 5.


Part 4 — Where memory actually goes in a service

Cause Fix
Loading a whole file/query into a list iterate — generators, yield, server-side cursors (Day 047)
Millions of small objects __slots__, NamedTuple, or array/NumPy
Repeated identical strings sys.intern, or an enum
Unbounded cache lru_cache(maxsize=...)never maxsize=None on user input
A list used as a queue deque — and pop(0) was O(n) anyway
Fragmentation after a spike restart workers periodically (--max-requests)
#    THE SINGLE HIGHEST-VALUE MEMORY HABIT IN PYTHON:
rows = [transform(r) for r in cursor.fetchall()]     # ⚠️    everything in RAM, twice
rows = (transform(r) for r in cursor)                #    one row at a time. Constant memory.

One character — a bracket to a parenthesis — is the difference between a service that handles a 10-million-row export and one that is OOM-killed. Day 047 makes generators a full day; this is the preview because it is the fix for most memory questions in a backend interview.

lru_cache(maxsize=None) on a function whose arguments come from user input is an unbounded cache keyed by attacker-controlled data. Bound it.


Common mistakes

Mistake Correction
Trusting sys.getsizeof on containers Shallow — it counts pointers, not targets.
__slots__ everywhere It costs flexibility. Use it for many small instances.
Forgetting __weakref__ in slots Silently breaks weakref.
Slots on one class in a hierarchy A slotless base restores __dict__.
is for numbers or strings Works by accident below 257 and for literals. Use ==.
Concluding CPython "caches all ints" −5..256, plus compile-time constant folding.
fetchall() on a large table Iterate. One character of difference.
lru_cache(maxsize=None) on user input Unbounded, attacker-controlled cache.
Expecting RSS to fall (Day 026) Arena reuse. Watch the trend, not the level.

Interview questions

Q: Why does a Python list of a million integers use so much more memory than a C array?

Because it isn't an array of integers — it's an array of pointers to integer objects. The list itself is eight megabytes of pointers, and each of the million integers is a separate 28-byte heap object with a refcount and a type pointer. So you're at roughly 36 megabytes, scattered across the heap, and every element access is a pointer dereference that's likely a cache miss. array.array or NumPy stores unboxed machine integers contiguously, which is eight megabytes and cache-friendly — and that's the real reason NumPy is fast: it isn't faster Python, it's no Python.

Q: What does __slots__ do and when would you use it?

It replaces the per-instance __dict__ with fixed storage, like a C struct, so attributes live at known offsets. That saves roughly half the memory per instance and makes attribute access slightly faster, at the cost of not being able to add attributes at runtime, needing an explicit __weakref__ slot, and requiring every class in the hierarchy to cooperate. I'd use it when there are many instances of a small fixed-shape class — graph nodes, parsed records, ticks — and not otherwise, because it's a memory optimisation with real ergonomic cost.

Q: Why is 256 is 256 True but 257 is 257 sometimes False?

CPython preallocates the integers from −5 to 256 and reuses those objects, so identity holds for them by accident of implementation. Above that, each literal usually creates a new object — except that within a single code object the compiler folds equal constants into one, so the same comparison inside a function can come out True. The real lesson is that identity of equal immutables is an implementation detail, which is exactly why is should only be used for None, the booleans, and genuine identity questions.

Q: What's string interning for?

Collapsing equal strings to a single object. Python does it automatically for identifier-like literals, because attribute and namespace lookups are dictionary lookups keyed by strings, and interning turns those comparisons into pointer comparisons. Explicitly, sys.intern is worth it when parsing produces millions of repeated strings — log levels, column names, tokens — where the duplicates dominate the memory.

Q: A service processing a large export gets OOM-killed. First thing you check?

Whether it's materialising the whole result. fetchall() into a list comprehension holds every row in memory, often twice — once as rows and once as transformed objects. Switching to iteration over the cursor with a generator expression makes it constant-memory, and it's a one-character change from a list comprehension. After that I'd look for unbounded caches, and for a list being used where a deque belongs.


Mini task

  1. sys.getsizeof a range of objects and write out where each byte goes.
  2. Build a list of a million ints and an array.array of the same. Compare memory and iteration time. (This closes Day 001.)
  3. Write Point and SlottedPoint, create a million of each, and measure the difference with tracemalloc.
  4. Try to set a new attribute on a slotted instance. Read the error.
  5. Subclass a slotted class without slots and confirm __dict__ reappears.
  6. Reproduce 256 is 256, 257 is 257 at the REPL and inside a function. Explain both.
  7. Intern a million duplicate strings and measure before and after.
  8. Write the fetchall() version and the generator version over a large file. Watch RSS in both.

Exit questions

Answer aloud, no notes.

  1. What is in the 28 bytes of an int?
  2. Why does getsizeof([1,2,3]) not include the integers?
  3. Give the memory arithmetic for a million-int list vs an array.array.
  4. What does __slots__ remove, and what does it cost — four costs?
  5. When is __slots__ worth it, and when is it noise?
  6. What is the small-integer cache and why those bounds?
  7. Why can 257 is 257 be True inside a function?
  8. What is interning, and why does Python intern identifiers?
  9. When would you call sys.intern deliberately?
  10. State the rule for using is.
  11. Name the one-character change that fixes most memory problems in a data path.
  12. Why is lru_cache(maxsize=None) dangerous on user input?

Articulation drill

Record two minutes: "Why is NumPy so much faster than a Python list?"

Start with memory, not speed, because the speed is a consequence: a Python list is an array of pointers to individually allocated objects — a million integers is eight megabytes of pointers plus 28 megabytes of scattered objects. NumPy stores unboxed machine values contiguously: eight megabytes, in order.

Then the two effects, in order of size: "first, the loop runs in compiled C rather than in the bytecode interpreter, so you lose the per-operation dispatch and allocation that Day 023 described. Second — and people underestimate this — the data is contiguous, so the CPU prefetcher works and you stop paying a cache miss per element. NumPy isn't a faster Python; it's an escape from Python for the duration of the loop, which is the same strategy as every other Python performance fix."


Previous: Day 027 · Tomorrow: Day 029 — truthiness, is vs ==, and the __eq__/__hash__ contract