A list of a million small objects uses far more memory than the data inside them. Where is it going?
Into per-object overhead: in CPython on a 64-bit build every object carries a 16-byte header, every field is itself a boxed object reached through an 8-byte pointer, and each instance has a dictionary. Slots remove the dictionary, and a columnar layout removes the objects.
What the interviewer is scoring
- Does the candidate account for the object header and the boxing of each field rather than only the field data
- Whether sys.getsizeof is identified as shallow before any measurement is trusted
- That key-sharing dictionaries are known, so the instance dict is not overstated
- Whether __slots__ is offered with what it takes away as well as what it saves
- Does the candidate consider abandoning per-row objects entirely rather than shrinking them
Answer
The data is the small part
In CPython on a 64-bit build, every object begins with a header holding a reference count and a pointer to its type. That is 16 bytes before anything you care about, and it is paid per object rather than per field. Since a Python integer, float or string is itself an object, a class with three numeric fields is not three numbers in a row: it is one instance object holding three pointers, each leading to a separate boxed value with its own header.
Work it through for a single float. Sixteen bytes of header plus eight bytes for the double gives twenty-four bytes to store a value that occupies eight in a C array, a threefold multiplier before the containing object exists. Then the instance holds an eight-byte pointer to it, the list holds an eight-byte pointer to the instance, and none of those pointers are the number.
That composition is the answer to the question. There is no single wasteful component; there is a fixed cost per object and a design that creates several objects per logical row.
The instance dictionary, and how bad it actually is
By default an instance stores its attributes in a dictionary, and the folklore is that this is enormous. It was, and since Python 3.3 it is less so, because key-sharing dictionaries let all instances of a class share one table of attribute names and keep only the values array per instance. Knowing that is worth credit, because it stops the answer being a decade out of date and it correctly identifies the remaining cost as the values array and the dictionary object itself rather than a full copy of the keys.
Two things still defeat the sharing. Assigning an attribute that other instances do not have forces that instance onto its own key table, and so does deleting one, which is why a class that conditionally sets attributes is measurably heavier per instance than one that always sets the same set in __init__.
Measure it, and know what the measurement excludes
sys.getsizeof is the obvious tool and it is shallow: it reports the size of the object you handed it and does not follow a single reference. For a list it returns the size of the pointer array, not the objects pointed at. For an instance it returns the object without its dictionary or its attribute values. So the two numbers that matter most are precisely the two it omits.
import sys
p = Point(1.0, 2.0, 3.0)
sys.getsizeof(p) # the instance only
sys.getsizeof(p.__dict__) # the attribute storage, counted separately
sys.getsizeof([p] * 1_000_000) # a million pointers, and no Points at all
For a real total, take a tracemalloc snapshot difference around the allocation and compare it with the resident size of the process. Walking the structure by hand works too, provided you count each object once by identity, since an object referenced twice must not be added twice. Reasoning about layout tells you where to look; only measurement tells you the number.
One more source of slack is the list itself. A list over-allocates so that repeated appends stay cheap, so its pointer array is larger than its length. Constructing it from a known-length iterable, or using a tuple where the sequence never changes, gives that back.
__slots__: what it removes and what it costs
Declaring __slots__ tells the class to reserve a fixed set of descriptors for its attributes and not to create an instance dictionary at all. The values live in the object itself at known offsets, which removes the dictionary object and the values array per instance and makes attribute access marginally faster. On a class with a handful of attributes and a large number of instances, this is the single biggest available win for the least change.
The costs are specific and worth volunteering. You can no longer set an attribute that was not declared, which is usually the point but does break libraries that annotate objects on the fly. Instances are not weak-referenceable unless you add __weakref__ to the declaration, which quietly breaks weak-value caches. And a subclass without its own __slots__ reintroduces a dictionary, giving the saving straight back. Since Python 3.10, @dataclass(slots=True) generates the declaration for you, which removes the main reason people skipped it.
The larger win is not having a million objects
Slots shrink each row. The step change comes from not representing rows as objects at all. If the million objects are homogeneous records being aggregated, filtered or scanned, a columnar layout stores each field once as a packed array of machine values with no header and no pointer per element, and the memory drops by an order of magnitude rather than a fraction. The array module does this for a single primitive type, and NumPy does it for several columns at once.
The trade is that you lose the object. Methods, per-row validation, sensible names and comfortable debugging all go, and a scan expressed as a Python loop over a NumPy array is slower than the same loop over a list because each element is boxed on access. So the columnar form pays off when the access pattern is whole-column arithmetic and loses when it is per-row logic, which means the question to answer before optimising is what the code does with these objects rather than how big they are.
When to leave it alone
A million objects at a few hundred bytes each is worth attention. A hundred thousand is not, and neither is a structure whose lifetime is one request. The trap is spending a week on layout when the collection did not need to be materialised at all: a generator that streams records past an aggregator has bounded memory whatever the input size. Shrinking each object buys one doubling of the data; not holding them all buys every doubling.
The cost is per object, not per byte of data: a 16-byte header, a boxed value behind every field, and a pointer for each reference.
__slots__removes the instance dictionary, but the order-of-magnitude change comes from storing columns of values instead of rows of objects.
Likely follow-ups
- Why does a dataclass with slots enabled behave differently from one without when you assign an unexpected attribute?
- How would you measure the true size of a nested structure, given that getsizeof does not recurse?
- What does a list do with its capacity as it grows, and how does that show up in memory?
- When is a single NumPy structured array the wrong answer despite being the smallest one?
Related questions
- What is a descriptor, and how does @property use one?hardAlso on slots5 min
- How would you rewrite a row-by-row pandas transformation, and what is SettingWithCopyWarning telling you?mediumAlso on numpy5 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on slots5 min
- Why does `is` agree with `==` for some numbers and strings but not others?mediumAlso on cpython5 min
- A promise rejects and nothing is awaiting it. What does Node do?hardSame kind of round: concept4 min
- You kick off background work from a controller action and it throws once the response has gone out. What is happening?mediumSame kind of round: coding5 min
- Find the cheapest route between two nodes in a weighted directed graph. Then tell me what breaks if one edge has a negative weight.mediumSame kind of round: coding4 min
- Sorting a list throws IllegalArgumentException saying the comparison method violates its general contract. What did the comparator get wrong?mediumSame kind of round: concept5 min