Why does sys.getsizeof on a list of dicts under-report the memory it really uses?
answer
- It stops at the first pointer
- Containers report their slots, not contents
- Walk referents to get a deep number
- Dedupe by id or cycles never end
- For growth over time, snapshot and diff
basics
~10 ssys.getsizeof is shallow: it reports only that object's own storage plus collector bookkeeping and never follows a pointer. For a list it counts the pointer array, not the dictionaries the pointers lead to.
solid answer
~40 s`sys.getsizeof(obj)` asks the object's `__sizeof__` and adds the garbage-collector bookkeeping, then stops. For a `list` that means the header plus the contiguous pointer array including spare capacity - roughly eight bytes per element regardless of what the elements are. A list of 1,000 small dictionaries reports about 8.9 KB while the structure genuinely costs over 220 KB once you count the dictionaries, their key tables and every string and integer inside. To get a deep figure, walk the graph with `gc.get_referents`, summing `sys.getsizeof` per object and deduping by `id` so shared objects are counted once and cycles terminate. For growth over time, `tracemalloc` snapshots and diffs are the better instrument, and process RSS is the number that actually gets you killed by a memory limit.
code
python · 16 linesimport gc
import sys
def deep_size(obj):
seen, stack, total = set(), [obj], 0
while stack:
item = stack.pop()
if id(item) in seen:
continue
seen.add(id(item))
total += sys.getsizeof(item)
stack.extend(gc.get_referents(item))
return total
rows = [{"id": i, "name": "sensor"} for i in range(1000)]
print(sys.getsizeof(rows), deep_size(rows))go deeper
Remember the one-line rule: sys.getsizeof measures the object itself, not the things it points to, so a container's number tells you almost nothing about the data inside it.
Explain the mechanics - __sizeof__ plus collector bookkeeping, the list's pointer array including spare capacity - and be able to write the gc.get_referents walk with an id based seen set.
Show the diagnostic ladder under memory pressure: shallow size to compare representations, a deep walk for one structure, tracemalloc diffs for growth, and RSS as the number the platform enforces.
Own what gets measured continuously. Decide which memory signal the team runs on in production and what threshold acts on it, so that a growing service is caught by instrumentation rather than by an out-of-memory kill.
### The contract `sys.getsizeof` actually offers `sys.getsizeof(obj)` returns the size **of that object alone**, in bytes. It calls the object's `__sizeof__` and adds the extra bookkeeping CPython attaches for the garbage collector. It does not follow a single pointer. For a container this means you get the container's own header plus its internal storage — for a `list`, the pointer array, including whatever spare capacity the list is holding — and nothing about what the pointers point to. That is why the numbers look absurd on real data. A list of 1,000 small dictionaries reports about 8.9 KB from `sys.getsizeof`, because 1,000 pointers plus slack is about all the list itself owns. Walk the graph and the same structure is over 220 KB: 1,000 dictionary objects, their key tables, and every string and integer inside them. ### Getting a deep size The standard recipe is a graph walk over `gc.get_referents`, which asks an object for the objects it directly refers to: ```python import gc, sys def deep_size(obj): seen, stack, total = set(), [obj], 0 while stack: item = stack.pop() if id(item) in seen: continue seen.add(id(item)) total += sys.getsizeof(item) stack.extend(gc.get_referents(item)) return total ``` Two details make or break it. **Dedupe by `id`, not by equality** — otherwise a shared object is counted once per referrer and a reference cycle loops forever. And understand what the number means: it is the sum of what those objects individually report, so a shared object is counted once for the whole structure, which is usually what you want but is not "how much RSS would I get back if I dropped this". The walk has real limits. `gc.get_referents` only sees what a type chooses to expose through its traverse function, so untracked leaves and objects holding memory outside the Python allocator can be undercounted; a C extension type that owns a large off-heap buffer may report a small `__sizeof__`. And the walk itself allocates, so do not run it on a structure you are simultaneously measuring. ### The other tool: `tracemalloc` `sys.getsizeof` answers "how big is this object". `tracemalloc` answers "what did the allocator hand out, and which line asked for it". Start it, take a snapshot, do the work, take another, and diff — you get the growth attributed to source lines, which is the question you usually have when memory climbs. It costs noticeable runtime overhead and it only sees allocations made after you started it, so it is a diagnostic tool rather than something to leave on. A rough division of labour: * `sys.getsizeof` — one object, one number, cheap, shallow. Good for comparing two representations of the same thing. * a `gc.get_referents` walk — "how big is this structure right now", with the caveats above. * `tracemalloc` snapshots and diffs — "what is growing, and where was it allocated". * process RSS — the only number the operating system and your container memory limit agree on, and the one that includes allocator fragmentation and freed-but-unreturned memory. ### What an interviewer is listening for The failure mode is treating `sys.getsizeof` as a memory profiler and concluding that a list of a million dictionaries "only uses 8 MB". The good answer names the shallow/deep distinction immediately, offers the `gc.get_referents` walk with the `id` dedupe, knows `tracemalloc` exists for growth over time, and ends at RSS — because that is the number that gets a process killed.
- Why must a deep-size walk dedupe by `id()` rather than by equality?Because the graph is a graph, not a tree. Two entries can point at the same object, so equality-based deduping would collapse distinct objects that merely compare equal, and without identity tracking a reference cycle makes the walk loop forever. Identity also matches the question being asked: how many distinct objects does this structure keep alive.
- When is `tracemalloc` the right instrument instead of a deep-size walk?When the question is "what is growing" rather than "how big is this". `tracemalloc` records what the allocator handed out and which line asked for it, so diffing two snapshots across a workload points at the leak. It only sees allocations made after it starts and adds real runtime overhead, so it is a diagnostic you switch on, not a permanent setting.
- Can a deep-size walk still undercount?Yes. `gc.get_referents` only reports what a type exposes through its traversal support, so objects holding memory outside the Python allocator - an extension type owning a large native buffer, for instance - can report a small size while pinning megabytes. When the deep number and process RSS disagree badly, trust RSS and look for off-heap buffers.
saying these in an interview costs you the question
- Treats sys.getsizeof as a memory profiler for containers
- Concludes a list of a million dicts uses only a few megabytes
- Walks referents without deduping, then loops on a cycle
- Sums getsizeof over elements and double-counts shared objects
- Ignores RSS, the number a memory limit actually enforces