skip to content

Custom Container Types

How you build a container of your own: subclass a built-in, wrap one with UserDict, or implement an abstract base class. Interviewers ask why a dict subclass ignores your __setitem__.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

16

Which special methods let a custom class support len(obj), obj[k] and obj[k] = v?

level: juniorimportance: must knowfreq 70%

answer

  1. Operators are syntax over dunder methods
  2. Three bracket operations, three methods
  3. Length, read, assign — and delete
  4. __len__ also decides truthiness
  5. IndexError versus KeyError matters

basics

~10 s

Python calls len for len(obj), getitem for reading obj[k], and setitem for assigning obj[k] = v. All three are looked up on the type, not the instance, and len must return a non-negative int.

solid answer

~40 s

`len(obj)` dispatches to `__len__`, `obj[k]` to `__getitem__`, `obj[k] = v` to `__setitem__`, and `del obj[k]` to `__delitem__`. Python looks these up on the **type**, so an attribute set on the instance is ignored. `__len__` must return a non-negative `int` — a negative raises `ValueError`, a non-int raises `TypeError` — and it doubles as the truth test: with no `__bool__`, an object whose length is 0 is falsy. `__getitem__` receives whatever sat inside the brackets: an `int`, a `slice` for `obj[1:5]`, a tuple for `obj[1, 'a']`, or a string key. Nothing normalizes it, so `obj[-1]` arrives as `-1` and you decide whether that means the last element. Raise `IndexError` for a bad integer index and `KeyError` for a missing key — the rest of the language keys off exactly those two.

code

python · 18 lines
python
class Deck:
    def __init__(self, cards):
        self._cards = list(cards)

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, key):
        return self._cards[key]

    def __setitem__(self, key, value):
        self._cards[key] = value


d = Deck(["A", "B", "C"])
print(len(d), d[0], d[-1], d[0:2])
d[0] = "Z"
print(d[0], bool(Deck([])))

go deeper

for a junior

Recall the mapping cold: len for len(), getitem for reading obj[k], setitem for assigning it. Being able to write a five-line class that wraps a list and supports all three is the whole bar here.

for a middle

Explain the mechanics: lookup happens on the type, len must return a non-negative int and drives truthiness, and getitem receives the raw key — int, negative int, slice or tuple — with no normalization.

for a senior

Show that exception choice is part of the contract: IndexError for a bad index, KeyError for a missing key, because built-ins key off those. Be ready to say why len must stay cheap when truth tests call it everywhere.

for a principal

Own the API decision: whether a type should look like a container at all, and how far to go — the bare four methods, or an abstract base class that derives the extras — given that every operator you expose is a contract callers will lean on forever.

## Subscription is a protocol, not a built-in privilege Nothing about square brackets is reserved for the types that ship with Python. The expression `obj[k]`, the statement `obj[k] = v`, the statement `del obj[k]` and the call `len(obj)` are surface syntax that the interpreter rewrites into lookups of four special ("dunder") methods on the object's type: | syntax | method | |---|---| | `len(obj)` | `__len__` | | `obj[k]` | `__getitem__` | | `obj[k] = v` | `__setitem__` | | `del obj[k]` | `__delitem__` | Implement them and your class behaves like a container everywhere the language expects one. Leave one out and only that operation fails — the protocol is à la carte, not all-or-nothing. A read-only view defines `__len__` and `__getitem__` and stops there; attempting `obj[k] = v` then raises `TypeError: 'X' object does not support item assignment`, which is exactly the right message. ## Lookup happens on the type, never on the instance Implicit special-method lookup skips the instance dictionary and `__getattr__` entirely: `obj[k]` resolves `type(obj).__getitem__`. Assigning `obj.__getitem__ = something` changes only what an *explicit* `obj.__getitem__(k)` call does; the bracket syntax keeps using the class's method. This is why per-instance monkeypatching of operators does not work, and why the dispatch is fast: the interpreter reads a slot on the type object rather than walking an attribute lookup. ## The `__len__` contract `__len__` must return an `int` that is zero or greater. Returning `-1` raises `ValueError: __len__() should return >= 0`; returning `"3"` raises `TypeError`. Beyond `len()`, it participates in truth testing: `bool(obj)` tries `__bool__` first and falls back to `__len__`, so once you define `__len__` the idiom `if my_container:` silently means "is it non-empty". An object with neither method is always truthy. Because truth tests are everywhere, keep `__len__` cheap — an implementation that runs a query or walks a linked structure turns an innocent `if` into real work. ## `__getitem__` gets the raw key The single biggest surprise for people coming from languages with an indexing operator is how little Python does on your behalf. Whatever is between the brackets is passed through untouched: * `obj[3]` → the `int` `3` * `obj[-1]` → the `int` `-1`, **not** normalized to `len(obj) - 1`. Wrapping negative indices is your job; `list` does it because `list` does it, not because the language does. * `obj[1:5]` → a single `slice` object; read `key.start`, `key.stop`, `key.step`, and call `slice.indices()` with your own length to clamp them. It is one call, never a loop of single-index calls. * `obj[1, 'a']` → the tuple `(1, 'a')`; multi-axis containers branch on `isinstance(key, tuple)`. * `obj['name']` → the string; a mapping-flavoured container simply treats the key space as strings. A container that wants to support both integers and slices branches on `isinstance(key, slice)` at the top of `__getitem__`, and by convention returns the *same* container type from a slice so that `obj[1:3]` composes with the rest of your API. ## Which exception to raise The choice is load-bearing, not stylistic. Raise `IndexError` when an integer index is out of range and `KeyError` when a key is absent. The rest of the language reads those two signals: the legacy iteration fallback and `reversed()` stop on `IndexError`, and mapping-shaped helpers key off `KeyError`. Raising a bare `Exception`, returning `None`, or raising `ValueError` for a missing key means built-ins that would otherwise work with your class either loop forever or blow up with a confusing traceback. `__setitem__` returns nothing useful — its return value is discarded, and an assignment expression is a statement, not a value. Augmented subscript assignment (`obj[k] += 1`) is not a fifth method: it is a `__getitem__`, then the in-place add, then a `__setitem__`. ## What you do not get for free Defining these four methods buys you the operators and nothing else. There is no `append`, `index`, `count`, `keys`, `+`, `*`, `==` or `sort`; equality still falls back to identity unless you write `__eq__`. That is a deliberate floor: the protocol is the minimum contract, and richer behaviour is either written by hand or inherited from an abstract base class that derives the extras from your two or three core methods. Start with the floor. Most custom containers in real codebases never need more than `__len__`, `__getitem__` and a well-chosen exception.

  • What does `__getitem__` receive when the caller writes `obj[1:5]` or `obj[1, 'a']`?
    A single `slice(1, 5, None)` object for the first, and the tuple `(1, 'a')` for the second — one call each, never a loop of single-index calls. Nothing unpacks them for you: a sliceable container branches on `isinstance(key, slice)` and reads `key.start`, `key.stop` and `key.step`, or calls the slice's `indices()` method with its own length to clamp them. A multi-axis container branches on `tuple` instead.
  • How does defining `__len__` change `bool(obj)`?
    Truth testing tries `__bool__` first and falls back to `__len__`, so an instance whose length is 0 becomes falsy and any positive length truthy. An object with neither method is always truthy. That means adding `__len__` silently redefines what `if my_container:` asks — usually what you want, but a trap if computing the length is expensive, since truth tests appear in far more places than explicit `len()` calls.
  • Why does raising `IndexError` rather than a generic exception matter for a custom container?
    `IndexError` is the agreed end-of-sequence signal. The legacy `__getitem__` iteration fallback and `reversed()` both stop when they see it, so a container that raises `ValueError` or a bare `Exception` for an out-of-range index makes `list(obj)` and `reversed(obj)` blow up instead of finishing. Missing keys use `KeyError` for the same reason: mapping-shaped helpers catch that specific type.

The bracket syntax is a plug and __getitem__ is the socket: the interpreter does not care what is wired behind the socket, only that your type installed one.

saying these in an interview costs you the question

  • Claiming len(obj) reads a .length attribute or field
  • Saying __getitem__ receives a normalized, always-positive index
  • Assuming obj[1:5] becomes repeated single-index __getitem__ calls
  • Thinking __len__ may return None, a float or a negative
  • Believing special methods are looked up on the instance
  • Raising a bare Exception instead of IndexError or KeyError

context

open as a page

What must you implement to subclass collections.abc.MutableMapping, and what comes free?

level: middleimportance: must knowfreq 55%

basics

~10 s

Five methods: getitem, setitem, delitem, iter and len. From those five the base class derives get, membership, keys, items, values, pop, popitem, clear, update, setdefault and equality, so you write the storage once.

open as a page

Why must a `tuple` subclass set its contents in `__new__` rather than in `__init__`?

level: middleimportance: must knowfreq 45%

basics

~20 s

A tuple's items are fixed when the object is allocated, and allocation happens in __new__. __init__ runs afterwards and cannot change them, so a tuple subclass that defines only __init__ fails at construction with a TypeError from tuple.__new__.

open as a page

Why does dict.update() skip an overridden __setitem__ in a dict subclass?

level: middleimportance: must knowfreq 50%

basics

~20 s

Because dict.update() is C code that writes straight into the dictionary's internal storage instead of calling the Python-level setitem you defined. dict.init, setdefault and the |= operator behave the same way, so an override is honoured only for plain d[key] = value assignment.

open as a page

Why does slicing a `list` subclass return a plain `list` instead of the subclass?

level: juniorimportance: should knowfreq 35%

basics

~20 s

Built-in list operations construct their result with the concrete list type rather than with type(self), so slicing, +, * and copy() all hand back a plain list. Override those methods yourself, or wrap a list with collections.UserList.

open as a page

What does defining __missing__ on a dict subclass change about d[key] lookups?

level: juniorimportance: should knowfreq 35%

basics

~20 s

When a key is absent, dict.getitem calls the subclass's missing with that key and returns whatever it returns, instead of raising KeyError. Only subscription d[key] triggers it: .get(), the in operator and .pop() never do.

open as a page

Why can Python iterate a class that defines __getitem__ but no __iter__?

level: middleimportance: should knowfreq 45%

basics

~20 s

iter() falls back to the old sequence protocol: it hands back a built-in iterator that calls getitem with 0, 1, 2 and so on until the object raises IndexError. No iter is needed for a for loop to work.

open as a page

Why are collections.abc.MutableMapping's inherited methods slow in a hot ETL export loop, and which should you override?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Every inherited method is generic Python code routed through your five abstract methods: membership and get catch a KeyError from getitem, update assigns one key at a time, and clear pops repeatedly, which is quadratic. Override the hot ones to delegate to the backing store.

open as a page

Why is isinstance(obj, collections.abc.Sequence) False for a class with __len__ and __getitem__?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Only the single-method ABCs — Iterable, Container, Sized, Hashable, Iterator, Reversible, Collection — check structurally for the methods they name. Sequence and Mapping define no such hook, so the check passes only for real subclasses or explicitly registered classes.

open as a page

Why does a cached total on a `list` subclass go stale when only `append` is overridden?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Built-in list mutators are C code that never calls your Python append: extend, +=, insert, slice assignment and sort change the contents behind the override, so the cache is never invalidated. Subclassing list gives you no single mutation hook.

open as a page

A webhook receiver dedupes with a dict subclass whose __missing__ registers the event id; after a membership guard was added, handlers fire twice. How do you diagnose it?

level: seniorimportance: should knowfreq 20%

basics

~20 s

The membership operator never calls missing, so the guard checks without registering anything and every redelivery looks new. Reproduce it with one assertion, then move registration out of the miss hook into an explicit method the guard calls.

open as a page

Why is an instance of a collections.abc.MutableMapping subclass unhashable by default?

level: juniorimportance: nice to knowfreq 15%

basics

~20 s

Because the base class defines eq and sets hash to None, so hash() raises TypeError: unhashable type. Anything defining equality without a matching hash is unhashable in Python, and a mapping whose contents change should not be a dict key anyway.

open as a page

How does reversed(obj) decide whether it can reverse a custom object?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

reversed() calls reversed if the type defines one. Otherwise it needs the full sequence protocol — both len and getitem — and walks indices from len(obj) - 1 down to 0. With neither route available it raises TypeError.

open as a page

What does collections.UserDict do differently from subclassing dict, and what does it cost?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

collections.UserDict stores a real dictionary in its data attribute and implements every mapping operation in Python on top of getitem and setitem, so one override covers the whole surface. The cost: it is not an instance of dict, and it is slower.

open as a page

What does collections.abc.MutableMapping.register(cls) give a class, and what does it not?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

It makes the class a virtual subclass, so isinstance and issubclass answer True. That is all: no mixin methods are added, no abstract method is enforced, and the base class never enters the class's method resolution order.

open as a page

Why does a `tuple` subclass with a nonempty `__slots__` fail at class-creation time?

level: seniorimportance: nice to knowfreq 12%

basics

~20 s

A tuple stores its items inline in the object, so instances of different lengths have different sizes and a slot has no fixed offset to live at. CPython rejects the class immediately with TypeError: nonempty __slots__ not supported for subtype of 'tuple'.

open as a page