Container Dunder Methods: __len__, __getitem__, and __contains__
- Defining __len__ and __getitem__ is enough to make a class work with len(), indexing, for loops, and even reversed() in many cases.
- __getitem__ receives a slice object, not just an int, when the caller writes obj[1:3] — you must handle both cases explicitly.
- Without __contains__, Python falls back to iterating the whole object with __getitem__ or __iter__ to answer x in obj, which is slower.
- __setitem__ and __delitem__ are what make bracket-assignment and del obj[key] work; omitting them makes your container read-only from the outside.
Why implement these instead of subclassing list
Subclassing list or dict directly works, but it inherits every method those types have, including ones that don't make sense for your data, and it locks in the underlying storage. Implementing the container dunder methods on a plain class instead gives you full control over storage and behaviour while still getting the built-in syntax — len(obj), obj[i], for x in obj, x in obj — for free, because those operators are just syntax sugar that call these methods.
__len__ and __getitem__: the minimum for iteration
__len__ backs the built-in len() function and must return a non-negative integer. __getitem__ backs the square-bracket indexing operator. Together, they're enough for Python to synthesize iteration even without __iter__: if a class has no __iter__, a for loop falls back to calling __getitem__ with 0, 1, 2, and so on until it raises IndexError.
class RingBuffer:
def __init__(self, capacity):
self._data = []
self._capacity = capacity
def push(self, item):
self._data.append(item)
if len(self._data) > self._capacity:
self._data.pop(0)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
return self._data[index]
buf = RingBuffer(3)
for x in (1, 2, 3, 4, 5):
buf.push(x)
print(len(buf)) # 3
print(buf[0]) # 3 (oldest surviving item)
for item in buf: # works without defining __iter__ at all
print(item) # 3, 4, 5
This fallback is convenient but has a real cost: it calls __getitem__ once per element with no way to short-circuit, so for large or computed sequences an explicit __iter__ generator is usually faster and clearer.
Supporting slices, not just single indices
When code writes obj[1:3] or obj[::2], Python doesn't call __getitem__ three times — it calls it once, passing a single slice object with .start, .stop, and .step attributes. If your __getitem__ assumes it always receives an integer, slicing will crash with a confusing TypeError deep inside your indexing logic:
class RingBuffer:
# ... __init__, push, __len__ as before ...
def __getitem__(self, index):
if isinstance(index, slice):
return self._data[index] # delegate slicing to the underlying list
return self._data[index] # plain int index also works via list
buf = RingBuffer(5)
for x in range(10):
buf.push(x)
print(buf[1:3]) # [6, 7] -- a real slice, not two separate __getitem__ calls
Delegating to an underlying list or tuple's own slicing, as above, is usually simpler and less error-prone than reimplementing slice.indices() arithmetic by hand.
__contains__ and the fallback Python uses without it
The in operator calls __contains__ if the class defines it. Without it, Python falls back to iterating the object (via __iter__ if present, otherwise the __getitem__ fallback described above) and comparing each element with == until it finds a match or exhausts the object. That fallback works correctly but is O(n) and does real iteration work every time; a class backed by a set or a sorted structure can usually answer membership far faster with an explicit __contains__:
class TagSet:
def __init__(self, tags):
self._tags = set(tags) # O(1) average membership via a real set
def __contains__(self, tag):
return tag in self._tags # explicit, fast path
def __iter__(self):
return iter(self._tags)
tags = TagSet(["python", "web", "cli"])
print("python" in tags) # True, via __contains__ directly -- no iteration needed
__setitem__, __delitem__, and full mutability
__setitem__(self, key, value) backs obj[key] = value, and __delitem__(self, key) backs del obj[key]. A class that defines __getitem__ but not __setitem__ is effectively read-only from the caller's point of view — attempting obj[0] = x raises TypeError: object does not support item assignment, which is often exactly the behaviour you want for something like an immutable view or a computed sequence:
class FrozenRecord:
def __init__(self, **fields):
self._fields = dict(fields)
def __getitem__(self, key):
return self._fields[key]
# no __setitem__ defined: assignment raises TypeError, by design
rec = FrozenRecord(name="Ada", role="engineer")
print(rec["name"]) # Ada
# rec["name"] = "x" # TypeError: 'FrozenRecord' object does not support item assignment
The complete set of container-related special methods, including __reversed__ and __length_hint__, is documented in the Python data model reference — worth a skim once you're implementing more than the basics.