skip to content

When do you choose a class decorator over a mixin base class or a metaclass?

level: principalimportance: should knowfreq 35%

answer

  1. Three ways to add behaviour to classes
  2. The axis is reach, not power
  3. Subclasses never re-run a decorator
  4. Inheritance shows up in isinstance
  5. Metaclass conflicts break multiple inheritance

basics

~20 s

Choose by reach and cost. A class decorator is a one-shot transform applied per class, visible at the point of use and easy to remove. A mixin joins the method resolution order and is inherited. A metaclass intercepts creation of a class and all its subclasses, and is the hardest to combine.

solid answer

~50 s

The deciding question is *how far the behaviour must reach*. A class decorator runs once on the class it sits above and never on subclasses, so it suits per-class opt-in work: registration, import-time validation, generated methods. A mixin base class puts behaviour into the method resolution order, so subclasses inherit it and can override it cooperatively -- the right tool when the behaviour is genuinely part of the type and callers should see it via `isinstance`. A metaclass intercepts the creation of every class in a hierarchy, which is the only way to enforce a policy that subclasses cannot forget, but it costs you composability: two bases with incompatible metaclasses cannot be combined. In practice `__init_subclass__` covers most of what people historically wanted a metaclass for. Prefer the least invasive option that reaches far enough, because inheritance and metaclasses are permanent in a way a decorator is not.

code

python · 17 lines
python
def sealed(cls):
    cls.sealed = True
    return cls


@sealed
class Base:
    pass


class Child(Base):
    pass


print(Base.sealed)                    # True
print("sealed" in Child.__dict__)     # False
print(Child.sealed)                   # True, inherited by lookup

go deeper

for a junior

Recall the one-line distinction: a decorator changes the single class it is written above, while a base class hands its behaviour to everything that inherits from it. Knowing that difference is enough here.

for a middle

Be able to explain reach concretely -- that a subclass inherits attributes a decorator left behind but never re-runs the decorator, and that a mixin's methods arrive through the method resolution order instead.

for a senior

Demonstrate the choice in a real design: which mechanism survives someone adding a subclass six months later, what breaks when two libraries each ship a metaclass, and when a base-class hook is the cheaper enforcement.

for a principal

Own the long-run cost. Argue about reversibility, about behaviour that acts at a distance versus behaviour a reader can grep, and about tooling visibility, then set the team default rather than deciding case by case.

### Three tools, one axis All three mechanisms add behaviour to classes; they differ in **when they run, how far they reach, and what they cost to undo**. | | runs | reaches subclasses | shows in `isinstance` | composes with others | |---|---|---|---|---| | class decorator | after the class is built | no | no | freely | | mixin base class | at attribute lookup | yes | yes | via MRO, usually fine | | metaclass | during class creation | yes | no | badly | ### The class decorator A decorator is called with the finished class and returns it, usually after mutating it. It is opt-in per class, written at the point of use where a reader will see it, adds nothing to the method resolution order, and can be deleted by removing one line. It cannot influence how the class was built, and crucially **it does not apply to subclasses**: ```python def sealed(cls): cls.sealed = True return cls @sealed class Base: pass class Child(Base): pass print(Base.sealed) # True print("sealed" in Child.__dict__) # False print(Child.sealed) # True, found on Base by lookup ``` `Child` inherits the *attribute* but the decorator never ran for it, so any per-class work -- registering, validating, generating methods from that class's own annotations -- silently skipped it. That single property decides most real choices: if forgetting the decorator on a subclass is a bug someone will make, a decorator is the wrong mechanism. ### The mixin base class A mixin is ordinary inheritance. Its methods are found through the MRO, subclasses get them automatically, overriding works with `super()` cooperation, and `isinstance(obj, Mixin)` is true -- which is exactly right when the behaviour is part of the object's identity and callers may reasonably branch on it. The costs are structural: it consumes a base-class slot, it becomes part of every subclass's MRO forever, and removing it later is a breaking change for anyone who type-annotated against it. It also cannot easily add *class-level* facts derived from the class itself, which is a decorator's speciality. ### The metaclass A metaclass participates in class creation, so it can inspect or rewrite the namespace before the class object exists, and -- unlike a decorator -- it runs for every subclass automatically. That is its unique power: policies that must not be forgettable. The price is composability. Metaclasses are inherited, and two base classes whose metaclasses are unrelated cannot be combined at all; the class statement fails outright. Library authors who ship a metaclass constrain every user of their hierarchy, which is why abstract base classes and their `ABCMeta` are conspicuous, deliberate exceptions rather than a pattern to copy. Since 3.6, `__init_subclass__` on a base class covers a large share of what metaclasses were used for -- running per-subclass hook code -- without a custom metaclass and without the conflict problem. That has moved the decision boundary: today a metaclass earns its keep mainly when you must intervene *before* the class object exists. ### How to actually decide 1. **Does it need to reach subclasses automatically?** If yes, a decorator is out; use a base class with `__init_subclass__`, or a metaclass if you must act before creation. 2. **Should callers see it in the type?** If `isinstance` should be true, that is inheritance, not decoration. 3. **Is it per-class data derived from the class itself?** Generated comparison operators, registration keys, computed attributes -- decorator territory, and the stdlib agrees: `@dataclasses.dataclass`, `@functools.total_ordering` and `@enum.unique` are all decorators. 4. **How reversible does it need to be?** A decorator is one line; a base class is in every subclass's MRO and in every annotation someone wrote. ### The organisational angle These mechanisms differ in how they behave in a codebase with many hands. A decorator is greppable and local: a reader sees `@register` above the class and knows something happened. A metaclass acts at a distance -- a class three levels down a hierarchy is transformed by code the reader may never open -- which is precisely why it is both powerful for enforcement and expensive for comprehension. When the goal is a rule the organisation must not break, invisibility is the feature; when the goal is a convenience, invisibility is a tax on everyone who debugs it later. One practical caveat for either generated-code approach: static type checkers do not execute your decorator, so attributes it injects are invisible to them unless you declare them. `typing.dataclass_transform`, added in 3.11, exists for exactly this -- it lets a decorator that generates dataclass-like methods tell checkers what it produces. Weigh that in: a mechanism your tooling cannot see is a mechanism your team debugs at runtime.

  • You need a policy every subclass must honour. Which mechanism, and why not a decorator?
    A base class hook or a metaclass, because both run for every subclass automatically. A decorator runs only on the class it annotates, so enforcement depends on every future author remembering to write it -- and the failure is silent, since the subclass still works and merely skips the check. Enforcement that can be forgotten is not enforcement.
  • What concretely goes wrong when two libraries each ship a metaclass?
    A class that inherits from both fails at the class statement: Python requires the metaclass of a derived class to be a subclass of the metaclasses of all its bases, and two unrelated metaclasses have no common derived type. The user's only escape is to hand-write a metaclass inheriting from both, which is why shipping one constrains everybody downstream.
  • How do these three choices interact with static type checkers?
    Inheritance is understood natively: a checker sees the mixin's methods on every subclass. Decorator-injected attributes are invisible unless declared, since the checker does not run the decorator -- `typing.dataclass_transform` exists to describe the dataclass-like case. Metaclass rewriting is the least legible of the three. Tooling visibility is a real input to the decision, not an afterthought.

A decorator is a sticker on one box, a mixin is a component welded into the design so every later model has it, and a metaclass is a change to the factory line that stamps every box that ever comes off it.

saying these in an interview costs you the question

  • Believes a class decorator applies to subclasses too
  • Reaches for a metaclass as the default answer
  • Cannot name the metaclass conflict on multiple inheritance
  • Ignores that inheritance changes isinstance results
  • Treats all three as interchangeable style choices
  • Forgets type checkers cannot see injected attributes

context