Skip to content

Custom Containers#

Python's data model lets you build objects that behave like built-in containers — supporting len, indexing, iteration, membership, and operators — by implementing the right dunder methods. Custom containers wrap internal storage while exposing a clean, Pythonic API.

How to use this page

Read after Data Structures and Dunder or Magic Methods. For traversal mechanics, see Custom Iterators. For ABC contracts, see Abstract Base Classes.

At a glance
Track Python Advanced → Data Model and Customization
Sections 12 major topics
Outline Use the right-hand TOC to jump

Topics: Which methods to implement? · Sequence example: fixed-capacity ring buffer · Mapping example: case-insensitive dict · Set-like container · Slicing protocol · __bool__ vs __len__ · Hashable immutable containers · Delegation pattern (wrapper containers) · … (+4 more)

  1. Which methods to implement?
  2. Sequence example: fixed-capacity ring buffer
  3. Mapping example: case-insensitive dict
  4. Set-like container
  5. Slicing protocol
  6. __bool__ vs __len__
  7. Hashable immutable containers
  8. Delegation pattern (wrapper containers)
  9. collections.UserList / UserDict / UserString
  10. Performance: __slots__
  11. When to subclass built-ins
  12. Testing container contracts

Which methods to implement?#

You want… Implement
len(c) __len__
c[i], c[i:j] __getitem__ (and optionally __setitem__, __delitem__)
for x in c __iter__ (or __getitem__ legacy)
x in c __contains__ (or rely on iteration)
c + d, c * n __add__, __mul__, etc.
bool(c) __len__ (non-zero → True) or __bool__
Hashable keys __hash__ + immutable __eq__

Minimal viable container: __len__ + __getitem__ + __iter__ (often via generator).


Sequence example: fixed-capacity ring buffer#

from collections.abc import Sequence

class RingBuffer[T](Sequence):
    def __init__(self, capacity: int) -> None:
        self._capacity = capacity
        self._data: list[T | None] = [None] * capacity
        self._size = 0
        self._head = 0

    def append(self, item: T) -> None:
        self._data[(self._head + self._size) % self._capacity] = item
        if self._size < self._capacity:
            self._size += 1
        else:
            self._head = (self._head + 1) % self._capacity

    def __len__(self) -> int:
        return self._size

    def __getitem__(self, index: int) -> T:
        if not isinstance(index, int):
            raise TypeError("index must be int")
        if index < 0:
            index += self._size
        if index < 0 or index >= self._size:
            raise IndexError("index out of range")
        return self._data[(self._head + index) % self._capacity]  # type: ignore[return-value]

    def __iter__(self):
        for i in range(self._size):
            yield self[i]

buf = RingBuffer[int](3)
for x in [1, 2, 3, 4]:
    buf.append(x)
list(buf)   # [2, 3, 4]
len(buf)    # 3
buf[0]      # 2

Inheriting collections.abc.Sequence provides mixin implementations for __contains__, __reversed__, index, and count when __getitem__ and __len__ exist.


Mapping example: case-insensitive dict#

from collections.abc import MutableMapping

class CaseInsensitiveDict(MutableMapping):
    def __init__(self, data: dict[str, str] | None = None) -> None:
        self._store: dict[str, str] = {}
        if data:
            for k, v in data.items():
                self[k] = v

    def _key(self, key: str) -> str:
        return key.casefold()

    def __getitem__(self, key: str) -> str:
        return self._store[self._key(key)]

    def __setitem__(self, key: str, value: str) -> None:
        self._store[self._key(key)] = value

    def __delitem__(self, key: str) -> None:
        del self._store[self._key(key)]

    def __iter__(self):
        return iter(self._store)

    def __len__(self) -> int:
        return len(self._store)

d = CaseInsensitiveDict({"Name": "Ada"})
d["name"]   # 'Ada'
"name" in d  # True

MutableMapping mixins supply .get, .update, .keys, .values, .items once the core five methods exist.


Set-like container#

from collections.abc import MutableSet

class OrderedUnique(MutableSet):
    def __init__(self, items=()) -> None:
        self._order: list = []
        self._seen: set = set()

    def add(self, item) -> None:
        if item not in self._seen:
            self._seen.add(item)
            self._order.append(item)

    def discard(self, item) -> None:
        if item in self._seen:
            self._seen.discard(item)
            self._order.remove(item)

    def __contains__(self, item) -> bool:
        return item in self._seen

    def __iter__(self):
        return iter(self._order)

    def __len__(self) -> int:
        return len(self._order)

Slicing protocol#

If __getitem__ receives a slice, return a new container or sequence:

class Tags:
    def __init__(self, items: list[str]) -> None:
        self._items = items

    def __getitem__(self, key):
        if isinstance(key, slice):
            return Tags(self._items[key])
        return self._items[key]

    def __len__(self) -> int:
        return len(self._items)

Tags(["a", "b", "c"])[1:3]  # Tags with ['b', 'c']

__bool__ vs __len__#

class Bag:
    def __init__(self) -> None:
        self._items: list = []

    def __len__(self) -> int:
        return len(self._items)

    # if __bool__ undefined, __len__ == 0 → falsy

bool(Bag())  # False

Define __bool__ when emptiness isn't the right truth condition (e.g., a config object that's "truthy" even when empty).


Hashable immutable containers#

class FrozenPoint:
    __slots__ = ("x", "y")

    def __init__(self, x: int, y: int) -> None:
        object.__setattr__(self, "x", x)
        object.__setattr__(self, "y", y)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, FrozenPoint):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __hash__(self) -> int:
        return hash((self.x, self.y))

{FrozenPoint(1, 2): "origin-ish"}

Rule: if __eq__ is defined, equal objects must have equal hashes. Mutable containers must not be dict keys.

Details in Classes and OOP Basics.


Delegation pattern (wrapper containers)#

class ListProxy:
    def __init__(self, data: list) -> None:
        self._data = data

    def __len__(self) -> int:
        return len(self._data)

    def __getitem__(self, index):
        return self._data[index]

    def __setitem__(self, index, value) -> None:
        self._data[index] = value

    def append(self, value) -> None:
        self._data.append(value)

Expose only the methods you want — hide mutators for read-only views.


collections.UserList / UserDict / UserString#

Subclass wrappers with a .data attribute — faster to prototype than full ABC implementation:

from collections import UserList

class SortedList(UserList):
    def append(self, item) -> None:
        super().append(item)
        self.data.sort()

SortedList([3, 1]).append(2)
# .data == [1, 2, 3]

Performance: __slots__#

class CompactStack:
    __slots__ = ("_items",)

    def __init__(self) -> None:
        self._items: list = []

    def push(self, x) -> None:
        self._items.append(x)

    def pop(self):
        return self._items.pop()

__slots__ reduces per-instance memory; you lose arbitrary attribute assignment and some multiple-inheritance flexibility.


When to subclass built-ins#

Subclass Risk
list, dict copy, pickle, C-speed paths may surprise
ABCs (MutableMapping) Clear contract, more boilerplate
Composition Safest — wrap, don't inherit
# prefer wrapping for production APIs
class Inventory:
    def __init__(self) -> None:
        self._skus: dict[str, int] = {}

Testing container contracts#

from collections.abc import Sequence

assert isinstance(my_container, Sequence)
assert len(my_container) == 3
assert list(my_container) == expected
assert my_container[1] == expected[1]

Use ABC isinstance checks to verify your object quacks correctly.


Interview traps (quick reference)#

Trap What goes wrong Safe approach
Mutable object as dict key TypeError or silent corruption Immutable or id-based keys only
__eq__ without __hash__ Sets/dicts break Define both or __hash__ = None
Negative index in __getitem__ Inconsistent with list Normalize index
Slice returns wrong type Surprising API Document return type
Subclass dict in __init__ Shared mutable class attr Compose internal dict

Mental model checklist#

  1. Which dunder methods are required for len, indexing, and for loops?
  2. What does inheriting MutableMapping give you for free?
  3. Why must hashable containers be immutable (or treat equality carefully)?
  4. When is composition better than subclassing list?
  5. How does __getitem__ support slicing?

What's next#

Topic Page
Dunder methods Dunder or Magic Methods
Custom iterators Custom Iterators
ABCs Abstract Base Classes
OOP basics Classes and OOP Basics
Data structures Data Structures