Skip to content

The data model & dunders

Senior

Dunder-method categories and object defaults, repr vs str, __call__/__format__/__del__/pickling hooks, __instancecheck__, and __dict__, vars, globals and __slots__.

Created 2 September 2026 · Updated 23 October 2026

15 concepts · detail hidden

  1. 01The major dunder-method categories, with a representative method from each

    The dunder methods fall into a handful of natural categories: lifecycle (__new__/__init__/__del__ — object creation and destruction); representation (__repr__/__str__/__format__ — how an object displays); comparison (__eq__/__lt__/__hash__ — equality and ordering); container/sequence (__len__/__getitem__/__contains__/__iter__); numeric (the __add__ family); context manager (__enter__/__exit__); callable (__call__); attribute access (__getattr__/__getattribute__/__setattr__); descriptor (__get__/__set__); class customization (__init_subclass__/__class_getitem__). Seeing these as categories rather than an isolated checklist of individual dunder names is what “the data model” actually refers to.

    #c-the-major-dunder-method-categories-with-a-representative-met
  2. 02What `object` provides by default for `__repr__`, `__eq__`, and `__hash__`

    The default __repr__ (inherited automatically from object) produces something along the lines of <ClassName object at 0x...> — showing the class name and raw memory address, genuinely not useful, meaningful information about the object’s actual state. The default __eq__ performs pure identity comparison (equivalent to is) — meaning two separately-created instances with identical field values are, by default, considered not equal unless you explicitly define your own __eq__. The default __hash__ is based on the object’s id(). This directly explains a common surprise: a plain, undecorated custom class needs an explicit __eq__ defined to support any kind of genuine value-based equality at all — it simply doesn’t happen automatically or “for free” just from defining a class.

    #c-what-object-provides-by-default-for-__repr__-__eq__-and-__ha
  3. 03`__call__` — making instances callable, and real uses beyond a fancy closure

    __call__ lets an instance of a class be invoked directly with the same instance(...) call syntax used for ordinary functions. Real production use cases beyond a toy example: a class-based decorator that also needs to maintain persistent state across calls; a configurable “strategy” object whose behavior is parameterized by state living on the instance itself, while still being usable at call sites exactly like a plain function; or a memoized/cached callable that additionally needs to expose extra methods (e.g., .clear_cache()) beyond what a purely closure-based approach could cleanly offer to callers.

    #c-__call__-making-instances-callable-and-real-uses-beyond-a-fa
  4. 04Why `__del__` is unreliable for critical cleanup — use a context manager instead

    __del__ genuinely isn’t guaranteed to run at any specific, predictable time — or, in some real edge cases, at all — including for objects still alive when the interpreter itself shuts down, or (historically, before Python 3.4’s cycle-collector improvements) objects trapped inside an uncollectable reference cycle involving __del__. Additionally, any exception raised inside __del__ is effectively swallowed and merely printed to stderr, rather than propagating normally the way an exception anywhere else in the program would — making failures inside __del__ genuinely easy to silently miss. And relying on refcounting’s typically-prompt collection timing to make __del__ feel reliable in practice is fundamentally a CPython implementation detail, not a language-level guarantee — PyPy’s own garbage collector, for instance, genuinely behaves differently here. The correct, reliable pattern for guaranteed cleanup remains a proper context manager (with/__exit__), not __del__.

    #c-why-__del__-is-unreliable-for-critical-cleanup-use-a-context
  5. 05`__format__` — how f-strings and `format()` delegate a custom format mini-language

    Both the built-in format(obj, spec) function and f-string interpolation syntax ({obj:spec}) ultimately call obj.__format__(spec) internally, passing along whatever text appears after the colon as the raw spec string, for the object itself to interpret however it chooses. This is precisely the mechanism that lets custom classes define and support their own bespoke “format mini-languages” — not merely the familiar built-in numeric/string format specifiers — simply by parsing that spec string themselves, inside a custom __format__ implementation.

    #c-__format__-how-f-strings-and-format-delegate-a-custom-format
  6. 06`__getstate__`/`__setstate__` — controlling pickling for unpicklable state

    pickle’s default behavior generally introspects an object’s __dict__ directly and reconstructs it later via its class — this default approach genuinely breaks down for objects holding state that simply cannot be meaningfully pickled at all (an open file handle, a live network socket, a thread lock, a weakref), or for objects that need genuinely custom reconstruction logic beyond naive attribute copying. __getstate__/__setstate__ let you take explicit control: __getstate__ decides exactly what gets serialized (e.g., deliberately excluding a live connection object entirely from the pickled representation), and __setstate__ controls exactly how the object is reconstructed on unpickling (e.g., re-opening a fresh connection at that point, rather than naively trying to restore the original, now-meaningless serialized connection state).

    #c-__getstate____setstate__-controlling-pickling-for-unpicklabl
  7. 07`__instancecheck__`/`__subclasscheck__` — the machinery behind virtual subclasses and runtime Protocols

    Defining these on a metaclass lets it override exactly what isinstance(obj, cls) and issubclass(sub, cls) actually check, replacing Python’s default MRO-walking nominal-inheritance behavior with genuinely custom logic. This is precisely the underlying mechanism that makes ABCMeta’s virtual subclass registration (SomeABC.register(SomeUnrelatedClass)) and runtime_checkable Protocols both work — enabling isinstance to correctly return True for objects that were never nominally, formally subclassed from the relevant type at all, purely because they’re structurally/behaviorally compatible, or explicitly registered as compatible. singledispatch piggybacks on this exact same ABC-registry machinery.

    #c-__instancecheck____subclasscheck__-the-machinery-behind-virt
  8. 08`__dir__` — surfacing dynamically generated attributes to `dir()` and autocomplete

    __dir__ controls precisely what dir(obj) reports back to the caller — the default implementation genuinely introspects the object’s real, concretely-existing attributes and methods. A class that instead synthesizes attributes dynamically, on demand, via __getattr__ (e.g., a dynamic API client proxying arbitrary endpoint names as if they were attributes) won’t have any of those “virtual,” synthesized attributes show up in dir() — or, critically, in IDE autocomplete — unless __dir__ is separately, explicitly overridden to report them. This genuinely matters for real-world usability and discoverability of any deliberately dynamic API.

    #c-__dir__-surfacing-dynamically-generated-attributes-to-dir-an
  9. 09`__str__` and `__repr__` — the one-directional fallback, and why containers use `repr` on elements

    The two methods have different contracts. __str__ is meant to produce a readable, user-facing display string (what print() and f-strings show). __repr__ is meant to produce an unambiguous, developer-facing representation — ideally one that, if passed to eval(), would reconstruct an equal object (Point(x=3, y=4)), which makes it genuinely useful in debuggers, logs, and the REPL because it shows precisely what the object is, not merely a friendly summary of it. When an eval-able form isn’t practical, the convention is the angle-bracket form <Connection host='db1' open>.

    class Point:
        def __init__(self, x, y):
            self.x, self.y = x, y
        def __repr__(self):
            return f"Point(x={self.x!r}, y={self.y!r})"   # eval-able, unambiguous
        def __str__(self):
            return f"({self.x}, {self.y})"                # friendly
    
    p = Point(3, 4)
    print(p)        # -> (3, 4)              uses __str__
    print([p])      # -> [Point(x=3, y=4)]   containers use repr on elements
    repr(p)         # -> 'Point(x=3, y=4)'
    

    If __str__ is left undefined, calling str(obj) automatically falls back to invoking __repr__ instead — but this relationship is genuinely one-directional: __repr__, if undefined, never falls back to a custom __str__ you might have defined; it simply uses object’s own default __repr__ implementation directly, entirely independent of whatever __str__ you wrote. A genuinely common, real surprise: built-in containers displaying their contained elements (e.g., print([my_obj])) always call repr() on each individual element, never str() — leaving a lot of developers confused about why their nicely-implemented __str__ output never actually shows up when their custom object appears nested inside a printed list or dict.

    #c-__str__-and-__repr__-the-one-directional-fallback-and-why-co
  10. 10An instance's `__dict__` vs a class's `__dict__` — what lives in each

    An instance’s __dict__ holds that specific object’s own, individual per-instance attributes — data assigned via self.x = ... inside that particular instance. A class’s __dict__ (technically returned as a read-only mappingproxy) instead holds everything genuinely defined at the class level — methods, class-level attributes, descriptors — all of which is shared across every single instance of that class rather than duplicated per instance. Ordinary attribute lookup on an instance checks that instance’s own __dict__ first, then falls back to the class’s __dict__, walked through the MRO in the usual attribute-lookup priority order.

    #c-an-instances-__dict__-vs-a-classs-__dict__-what-lives-in-eac
  11. 11Why a class's `__dict__` is a read-only `mappingproxy` but an instance's is a mutable `dict`

    mappingproxy is a deliberately read-only view, specifically to prevent bypassing the proper class-creation/metaclass machinery by simply poking arbitrary entries directly into a class’s namespace after the fact — class-level attribute assignment is meant to genuinely go through type.__setattr__ (which can, among other things, correctly trigger any relevant metaclass hooks along the way), rather than allowing silent, unmediated direct dict mutation to sidestep that entirely. An instance’s own __dict__, by contrast, has no such protective layer by default — direct mutation (instance.__dict__['x'] = 5) is fully legal and behaves exactly equivalently to the ordinary instance.x = 5 for genuinely everyday, plain attribute assignment.

    #c-why-a-classs-__dict__-is-a-read-only-mappingproxy-but-an-ins
  12. 12Why built-in immutable types have no per-instance `__dict__`

    It raises AttributeError — built-in immutable types are deliberately implemented without any per-instance __dict__ at all, for two combined reasons: genuine memory efficiency, given the sheer scale at which these fundamental types are constantly created and used throughout any real program, and the simple fact that these types are immutable, so there’s fundamentally nothing meaningful to dynamically add to a given instance after creation anyway. This connects directly to __slots__: defining __slots__ on your own custom class is deliberately opting your instances into this exact same “no per-instance __dict__” memory-saving behavior that built-in types already get automatically, for free.

    #c-why-built-in-immutable-types-have-no-per-instance-__dict__
  13. 13`vars(obj)` vs `obj.__dict__` — and how no-argument `vars()` differs

    vars(obj), called with a single argument, is essentially just a thin, convenient wrapper that returns obj.__dict__ directly — they refer to the exact same underlying dict object, not a copy of it; mutating whatever vars(obj) returns genuinely mutates the instance’s real attribute storage directly, precisely the same as mutating obj.__dict__ itself would. vars() called with no arguments at all, from inside a function, is instead equivalent to locals() — a genuinely, meaningfully different (and considerably more dynamic and generally unreliable to mutate with any real effect) thing, worth carefully distinguishing from the single-argument form.

    #c-varsobj-vs-obj__dict__-and-how-no-argument-vars-differs
  14. 14Legitimate uses for reading or writing `globals()` programmatically

    Genuine, legitimate uses: dynamically registering a whole family of similarly-shaped functions or classes into a module’s top-level namespace from within a loop (a real metaprogramming pattern generating related code from a shared specification), or plugin-loading code that genuinely needs to programmatically inspect exactly what’s currently defined at the module’s top level. Worth genuine skepticism too, though: reaching for globals() outside of a narrow, deliberate, well-understood use case like these is usually more a sign of avoiding a cleaner, more explicit namespace/data-structure design than a genuinely necessary technique.

    #c-legitimate-uses-for-reading-or-writing-globals-programmatica
  15. 15`__slots__` and `__dict__` — and how a subclass without `__slots__` silently regains one

    Accessing instance.__dict__ on a properly __slots__-using base class raises AttributeError — no __dict__ exists at all, by deliberate design, since that absence is precisely the whole point and mechanism behind __slots__'s memory savings. But — a genuinely common, easy-to-miss real gotcha — if a subclass of that base class doesn’t also explicitly declare its own __slots__ (even an empty __slots__ = ()), its own instances silently regain a full __dict__ after all: the plain absence of a __slots__ declaration in the subclass quietly reintroduces the default per-instance dict there, entirely defeating the memory savings the base class was originally trying to enforce throughout the hierarchy.

    class Base:
        __slots__ = ("a",)
    
    class Leaky(Base):
        pass                # forgot to declare __slots__
    
    class Tight(Base):
        __slots__ = ()      # explicitly claims "no new attributes"
    
    leaky = Leaky()
    leaky.a = 1
    leaky.b = 2             # works — Leaky instances have a __dict__ after all
    leaky.__dict__          # {'b': 2}
    
    tight = Tight()
    tight.a = 1
    tight.b = 2             # AttributeError: 'Tight' object has no attribute 'b'
    

    Leaky still benefits from Base’s slot for a, but every instance also carries a full __dict__ for anything else — so it pays for both layouts at once, with none of the memory saving __slots__ was meant to provide. Tight is the version that actually preserves it: an empty __slots__ = () adds no new attributes but, crucially, adds no __dict__ either.

    #c-__slots__-and-__dict__-and-how-a-subclass-without-__slots__-