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.
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
listvsarray.arraymeasurement into an explanation, and it produces the two facts that makeisbehave 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.arrayor 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
isshould only be used forNone, 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.internis 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 adequebelongs.
Mini task
sys.getsizeofa range of objects and write out where each byte goes.- Build a list of a million ints and an
array.arrayof the same. Compare memory and iteration time. (This closes Day 001.) - Write
PointandSlottedPoint, create a million of each, and measure the difference withtracemalloc. - Try to set a new attribute on a slotted instance. Read the error.
- Subclass a slotted class without slots and confirm
__dict__reappears. - Reproduce
256 is 256,257 is 257at the REPL and inside a function. Explain both. - Intern a million duplicate strings and measure before and after.
- Write the
fetchall()version and the generator version over a large file. Watch RSS in both.
Exit questions
Answer aloud, no notes.
- What is in the 28 bytes of an
int? - Why does
getsizeof([1,2,3])not include the integers? - Give the memory arithmetic for a million-int list vs an
array.array. - What does
__slots__remove, and what does it cost — four costs? - When is
__slots__worth it, and when is it noise? - What is the small-integer cache and why those bounds?
- Why can
257 is 257be True inside a function? - What is interning, and why does Python intern identifiers?
- When would you call
sys.interndeliberately? - State the rule for using
is. - Name the one-character change that fixes most memory problems in a data path.
- 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