AlgoMaster Logo

Class Decorators

Medium Priority23 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

The term "class decorator" gets used for two completely different things in Python, and the confusion bites people who only learn one. The first is a class used as a decorator: a class whose instances behave like functions, wrapping another callable so it can carry state across calls. The second is a decorator applied to a class: a function that takes a class object, modifies or registers it, and hands it back. Both share the @ syntax. Neither replaces the other. This lesson covers both, in that order, with e-commerce examples that show when each one earns its place.

Meaning A: A Class Used As a Decorator

Function-based decorators close over their state in an enclosing scope. That works fine for one or two variables, but the moment you want to inspect that state from outside, reset it, or subclass the behavior, the closure starts to feel cramped. A class wrapping a function gives you a proper object to hold onto: attributes you can read, methods you can call, a type you can extend.

The mechanics are short. Any class whose instances are callable can stand in for a function-based decorator. Python only needs two things from a callable class: an __init__ that receives the function being wrapped, and a __call__ that runs when the instance is invoked.

Walk through what happened. The line @Trace above add_to_cart is the same as writing add_to_cart = Trace(add_to_cart) after the function definition. That call runs Trace.__init__ with the function as the fn argument, storing it on the new instance. The name add_to_cart in the surrounding module is now bound to that Trace instance, not to the original function.

When you write add_to_cart("mug", 2), you are no longer calling a function. You are calling an instance of Trace. Python looks up __call__ on the class, runs it with self set to the instance and the rest of the arguments forwarded. Inside __call__, self.fn is still the original function, so calling it produces the real result.

The diagram traces the path from the decorated definition to a single call. The @Trace line builds an instance, and every later call routes through __call__, which forwards the real work to the original function held on self.fn.

Why a Class At All?

The example above could have been a plain function-based decorator. The class form pays for itself the moment you want to do something a closure cannot easily do: expose state, reset it, share it across decorated functions, or build on it through subclassing. Here is a CallCounter that records how many times a wrapped function has been called and lets the rest of the program read that count.

Two things are happening here that a function-based decorator could not give you for free. First, add_to_cart.count is a real attribute on a real object. You read it like any other attribute, no global, no nonlocal, no awkward function attributes. Second, add_to_cart.reset() is a real method. The decorated function gained a method just by being decorated, and the rest of the codebase can treat it as a counter the same way it would treat any other object.

The same effect with a closure looks like this:

It works, but the surface is awkward. wrapper.count is now a function you have to call (wrapper.count()), or you have to recompute and reassign the attribute on every increment. The class form makes self.count a normal attribute that just is the number, and any IDE or dir() listing shows it that way.

Tracking State Across Calls

Stateful decorators are where the class form really earns its keep. Picture an admin tool that wants to know which cart operations are getting the most use. A single CallCounter instance per function tracks that.

Each decorated name now holds its own CallCounter instance with its own count. The loop at the bottom walks all three and reads their counters directly. Notice fn.fn.__name__: the outer fn is the CallCounter, and fn.fn is the original function stored on self.fn. The name lookup falls through to the function because the CallCounter instance itself does not have a __name__ attribute by default. We will fix that in a moment with functools.update_wrapper.

Holding More Than Just State: A Simple Rate Limit

State is not the only thing a class can carry. A rate-limiter needs both state (when was the last call?) and configuration (how many calls per second do you allow?). The class form makes both fit naturally.

This decorator is shaped a little differently from CallCounter, and the difference matters. @RateLimit(min_interval_seconds=0.5) takes a configuration argument. So RateLimit(0.5) builds an instance first, and then @instance is applied to place_order. Python calls the instance with the function as argument, which is why __call__ here receives fn, not the runtime arguments. The class returns a closure (wrapper) that does the per-call work and reads self.min_interval and self.last_call from the enclosing instance.

Compare that to CallCounter, which was used as @CallCounter (no parentheses). For CallCounter, __init__ got the function and __call__ ran on every call. For RateLimit, __init__ got the config and __call__ got the function. Both are class decorators (Meaning A). The shape depends on whether the decorator takes arguments.

FormConstructor receives__call__ receives
@SomeClassthe function fnruntime args
@SomeClass(config)the configthe function fn

Both are common, and you will see both in the wild. Pick the form that matches whether your decorator needs configuration.

Keeping Identity: functools.update_wrapper

A function-based decorator usually copies the wrapped function's __name__, __doc__, and other metadata onto the wrapper using functools.wraps. The same care matters for class-based decorators, otherwise debugging output and documentation tools see the class instance instead of the original function.

The instance has no __name__ at all. The wrapped function does, but it is hiding behind self.fn. The fix is functools.update_wrapper(self, fn) in __init__, which copies the standard set of attributes from the function onto the instance.

functools.update_wrapper is the same machinery functools.wraps uses internally. The difference is that wraps is a decorator factory built for the function-based case, while update_wrapper is the underlying function and works on any object, including class instances.

Subclassing for Variants

The other big win of the class form is that decorators can inherit from each other. Subclass CallCounter and you get a new decorator that adds behavior without rewriting the counter logic.

LoudCallCounter reuses the counter logic by calling super().__call__, then prints a status message. You cannot do this cleanly with a closure-based decorator. Inheritance is the kind of feature that only feels essential when you actually need it, and when you do, the class form is the only one that gives it to you for free.

One Caveat: Methods on Classes

A class-based decorator does not automatically work as a method decorator. Try to put @CallCounter on an instance method and the wrapping breaks subtly.

The error message reads strangely until you see what is happening. When @CallCounter runs on the method, it replaces add on the class with a CallCounter instance. Accessing cart.add does not bind self to the call, because CallCounter is not a function and does not implement the descriptor protocol. So cart.add("mug") passes "mug" as the first and only argument, with no self in sight.

Fixing this requires giving CallCounter a __get__ method that returns a bound version when accessed through an instance. That falls under the descriptor protocol, which is its own topic. If your class-based decorator must work on methods, either add __get__ (the descriptor approach) or keep the decorator as a function and use the closure pattern, which handles method binding automatically.

Meaning B: A Decorator Applied To a Class

Switch contexts. Everything above was about classes that act as decorators. The other half of "class decorators" is the opposite: decorators that are applied to a class definition. The @ line sits above a class block instead of a def block, and the decorator receives the class object itself.

The pattern is identical to function decoration in shape. The @announce line is the same as writing Order = announce(Order) after the class block. The decorator receives the freshly-built class as its single argument and returns it (or a replacement). Inside, you can do anything to the class: read its attributes, add methods, register it in a global table, freeze it, wrap its __init__.

The diagram captures the lifecycle. Python builds the class object first, runs all the inner defs, attaches them as methods, then hands the finished class to the decorator. The decorator does its work after the class exists, not during construction. That timing is what makes class decorators useful: by the time the decorator sees the class, you can inspect it as a finished, fully-populated object.

Registering Classes in a Lookup Table

One of the most common uses for a class decorator is registration. An e-commerce backend might support several payment methods, each implemented as its own class. Instead of writing a long if/elif chain that maps strings to classes, you can have each class register itself when it is defined.

The registry pattern has a few nice properties. New payment methods are added by writing a class and decorating it; no central list to update. The pay function does not import the concrete classes directly, it only knows about the registry. The whole dispatch becomes data-driven.

Notice the shape of the decorator. register_payment_method takes a name argument and returns the real decorator. The real decorator (decorator) receives the class, stuffs it into the dict, and returns the class unchanged. Returning the class is important: if you accidentally returned None, the name (CardPayment, UpiPayment, etc.) would be bound to None in the surrounding module, and any code that referenced the class directly would break.

Adding Methods to a Class

A class decorator can also modify the class it receives. Adding a method is the simplest example. Suppose every order class should have a __repr__ that shows its fields, but writing one for each class is tedious. A decorator can synthesize it.

The decorator defines a __repr__ function and attaches it to the class via cls.__repr__ = __repr__. After the decorator returns, every instance of Order and Customer has the new __repr__ available. The function captures cls in its closure so it can use the class name in the output.

This pattern is exactly the spirit of what @dataclass does at a much grander scale: synthesize methods (__init__, __repr__, __eq__, and more) from the class's annotations.

Adding Class Attributes

The same write-access works for attributes, not just methods. A decorator can stamp a version number, a default config, a frozen schema, or any other class-level value onto whatever it decorates.

The decorator factory tagged takes the version string and returns the inner decorator. The inner decorator sets two class attributes and returns the class. Instances inherit the attributes through the usual attribute lookup, so CheckoutFlow().version would work just as well as CheckoutFlow.version.

Modifying __init__

A class decorator can wrap an existing method, including __init__. This is how some validation libraries enforce invariants on construction.

The decorator captures the original __init__ as a local variable, defines a new one that calls the original and then runs the validation, and reassigns it to cls.__init__. Every instance constructed after the decoration goes through the wrapper.

The pattern of "save the old method, build a new one that calls it plus extra logic, write it back" is the most common shape of class-modifying decorators. It is the imperative version of inheritance: instead of subclassing and overriding __init__, you stamp the override directly onto the class.

Freezing a Class

A class decorator can also enforce a property over the class's runtime behavior, like preventing new attributes from being added after construction. Here is a freeze decorator that makes instances refuse new attributes.

The decorator overrides both __init__ (to set the _frozen flag after construction) and __setattr__ (to reject writes once the flag is set). Inside the new __init__, assignments to self.order_id and self.total go through the wrapped __setattr__, but _frozen is still False at that point, so the writes succeed. After __init__ returns, _frozen is True, and any further assignment raises.

The detail worth pausing on is self.__dict__["_frozen"] = True instead of self._frozen = True. The second form would route through our own __setattr__, and at that point _frozen is already False from the original __init__, so it would succeed. The direct dict assignment sidesteps the wrapper. The same kind of "trapdoor write" shows up in several real frozen-class implementations.

Returning a Replacement

A class decorator does not have to return the same class. It can return a different class entirely, which lets the decorator pull tricks like wrapping the class in a proxy or swapping its base class. The common form, however, is to return the original class. Returning something else surprises readers and breaks isinstance checks that callers might write against the decorated name.

(The exact second line of output will include the full method resolution order; the point is that Order is no longer the original class, it is a subclass.) Most readers expect @some_decorator class Order to leave Order as Order. Replacing the class works, but it is a stronger move that needs a real reason behind it.

Real-World Examples Worth Knowing

A few class decorators in the standard library are worth recognizing. They use exactly the mechanics above to deliver a lot of value.

DecoratorWhat it doesWhere it lives
@dataclassSynthesizes __init__, __repr__, __eq__ from class annotationsdataclasses
@total_orderingFills in the rest of the comparison methods from one or two of themfunctools
@register (abc)Registers a class as a virtual subclass of an ABCabc.ABCMeta.register

@total_ordering is a small helper that saves you from writing all six comparison methods when one or two are enough to derive the others. Both demonstrate the same patterns this lesson covered: inspect the class, add methods, return the class unchanged.

Comparing the Two Meanings Side by Side

The two halves of this lesson share the @ syntax and almost nothing else. Side-by-side they look like this:

AspectMeaning A: class as decoratorMeaning B: decorator applied to class
What sits below the @ lineA function (def)A class (class)
What the decorator isA class with __call__A function (or callable)
What the decorator receivesThe function (in __init__ or __call__ depending on form)The class object
What the decorator returnsAn instance (or a closure wrapping the function)The class (usually unchanged), or a replacement
Typical reason to reach for itNeed state, configuration, methods, or subclassing on the wrapperNeed to register, modify, or augment a class
Typical example@CallCounter, @RateLimit(0.5)@register_payment_method("card"), @dataclass

Both are powerful, and you will write both in real code. The trick is to recognize which one you are looking at when you read someone else's. The shape of what sits below the @ line tells you immediately.

A handful of decorators (notably @dataclass) only make sense in Meaning B; they would not work above a function. A handful (notably @CallCounter from this lesson) only make sense in Meaning A. Most are clearly one or the other from context.

Putting Both Together

Just to drive the point home: you can use both kinds of class decorator in the same program without conflict. They live in different worlds.

CardPayment is decorated by register_payment_method, a Meaning B decorator that adds the class to the lookup table. pay is decorated by CallCounter, a Meaning A decorator that wraps the function in a callable instance that counts invocations. Both work, both are read naturally by anyone fluent in Python, neither interferes with the other.

A Common Trap: Forgetting to Return the Class

The single most common bug with Meaning B is forgetting to return the class from the decorator.

What's wrong with this code?

The decorator function does not return anything, so it implicitly returns None. After @register, the name CardPayment is bound to None, not to the class. The registry still has the class (because we stored a reference in the dict), but every other use of CardPayment is broken.

Fix:

Always return the class (or a replacement) from a class decorator. The same rule applies to a wrapping decorator from Meaning A: return the wrapper or the instance, never None.

Quiz

Class Decorators Quiz

10 quizzes