What breaks when a Python class decorator returns a wrapper function instead of the class?
answer
- The return value replaces the class name
- Instances are fine; the name is not
- isinstance wants a type as second argument
- pickle re-imports the class by qualified name
- Mutate and return cls, or subclass
basics
~20 sThe decorated name is rebound to whatever the decorator returns. Return a function or a proxy instance and that name is no longer a class: isinstance and issubclass raise TypeError, nothing can subclass it, and pickle cannot find the type.
solid answer
~50 s`@deco` above a `class` statement runs the class body first, then rebinds the class name to `deco(cls)` — and Python never checks that the result is a class. If the decorator returns a wrapper function or a proxy instance, calling the name may still produce instances, but the name itself is no longer a type: `isinstance(obj, Name)` and `issubclass(X, Name)` raise `TypeError`, `class Sub(Name)` fails because the metaclass is derived from a function, class attributes and alternate constructors reached through the name raise `AttributeError`, and `pickle` refuses instances because `module.Name` is no longer the class it recorded. Zero-argument `super()` inside the original methods still works, since it reads the compiler's `__class__` cell, but `super(Name, self)` does not. The safe patterns are to mutate the class and `return cls`, or to return a genuine subclass carrying the original `__qualname__` and `__module__`.
code
python · 27 linesimport pickle
def wrapping(cls):
def factory(*args, **kwargs):
return cls(*args, **kwargs)
return factory
@wrapping
class Point:
def __init__(self, x):
self.x = x
p = Point(3)
print("instance type:", type(p).__qualname__)
try:
isinstance(p, Point)
except TypeError as exc:
print("isinstance ->", exc)
try:
class Point3D(Point):
pass
except TypeError as exc:
print("subclassing ->", type(exc).__name__)
try:
pickle.dumps(p)
except pickle.PicklingError as exc:
print("pickle ->", type(exc).__name__)go deeper
Be ready to say that @deco above a class statement rebinds the class name to whatever the decorator returns, and that a decorator missing its return cls leaves the name bound to None.
Explain the concrete failures and their mechanism: isinstance and issubclass raising TypeError on a non-type, class Sub(Name) failing because the metaclass comes from the bases, and class attributes vanishing because a function has no class namespace.
Show you have debugged the delayed form of this — a pickling error when instances first cross a process boundary, or a downstream subclass that stopped importing — and that you fixed it by returning the class rather than by patching every call site.
Own the contract your library's class decorators publish: decide whether they promise identity preservation, document it, and enforce it in review or with a type[T] -> type[T] signature so that downstream subclassing, registries and serialization stay possible.
**The desugaring is the whole story.** Writing ```python @deco class C: ... ``` is exactly the same as writing ```python class C: ... C = deco(C) ``` The class body executes first and a real class object is built by the metaclass; only then is the callable applied and the *name* `C` rebound to its return value. Nothing in the language checks that the return value is a class. A decorator that forgets its `return` binds `C` to `None`; one that returns a nested wrapper function binds `C` to a function; one that returns a proxy instance binds `C` to an ordinary object. The instances the class produces are untouched — their `type` is still the original class — but every use of the *name* now goes through something that is not a type. **What breaks, and why** *Type tests.* `isinstance(obj, C)` and `issubclass(D, C)` require the second argument to be a type, a tuple of types or a union. Handed a function they raise `TypeError`; they do not return `False`. This surprises people because `obj` really is an instance of the original class — the argument that is wrong is the second one. *Subclassing.* `class D(C): ...` derives the metaclass from the bases by calling `type(C)`. When `C` is a function, Python tries to call `function(...)` with the name, bases and namespace, and raises `TypeError`. A library whose classes are meant to be extended cannot be decorated this way at all. *Class-level access.* Constants, `classmethod` alternate constructors and `staticmethod` helpers reached through the name — `C.from_json(...)`, `C.DEFAULT` — raise `AttributeError`, because the function does not carry the class's namespace. Copying metadata with `functools.wraps` fixes `__name__` and `__doc__` and helps not at all: the object is still a function. *Explicit `super`.* Zero-argument `super()` inside the original methods still resolves. The compiler stores the defining class in a `__class__` closure cell created by the class body, and rebinding a module global does not touch that cell. But an explicit `super(C, self)` written anywhere reads the *name*, gets a function, and raises `TypeError`. That asymmetry is worth being able to state. *Pickling.* `pickle` serializes an instance by recording the class's `__module__` and `__qualname__`, then, at dump time, importing that path and checking that the object found there **is** the class being pickled. With the name rebound, the lookup returns the wrapper, the identity check fails, and `pickle.dumps` raises `PicklingError` before any instance state is written. This is the failure that reaches production latest, because it only appears when instances cross a process boundary — a process pool, a cache, an on-disk snapshot. Note that `copy.deepcopy` still works: it drives the same reduce protocol but never looks the class up by name. *Typing and introspection.* A static type checker follows the decorator's declared return type; a decorator annotated as returning a plain callable erases the class from the checker's view, so attribute access through the name stops being verified. Anything that walks `__mro__`, or generates documentation from a type, degrades the same way. **The safe patterns** 1. **Mutate and return the same object.** Add attributes and methods to `cls`, then `return cls`. Identity is preserved, so none of the failures above can happen. This is what the standard library's own class decorators do — `dataclasses.dataclass` hands back the very class it was given, with `slots=True` as the documented exception, where it must build a new class. 2. **Return a genuine subclass.** `type(cls.__name__, (cls,), namespace)` keeps the name bound to a class, so type tests, subclassing and class-attribute access all behave. Copy `__qualname__` and `__module__` from the original so `pickle` resolves the name to the new class, and remember that identity is still not preserved: instances built by code holding the undecorated class are not instances of the subclass, and a registry keyed by class object will see two distinct keys. 3. **Do not use a class decorator at all** when what you actually want is to intercept construction or wrap behaviour. Override `__new__` or `__init__`, wrap individual methods, or expose a separate factory function under its own name and leave the class name bound to a class. **Spotting it in review.** The tell is a class decorator whose body defines a nested `def` and returns it, or one that returns nothing because the author forgot the `return` on the last line. A single assertion at the end of the decorator — that the result is an instance of `type` — or simply annotating the decorator as taking and returning `type[T]` catches both before they ship, and costs nothing at runtime.
- Does returning a genuine subclass from a class decorator avoid all of these problems?Almost. The name stays bound to a class, so type tests, subclassing and class-attribute access all work, and copying `__qualname__` and `__module__` from the original keeps `pickle` resolving the name. What it does not preserve is identity: the decorated class is not the original object, so instances built by code holding the undecorated class fail an `isinstance` test against it, and a registry keyed by class object ends up with two distinct entries.
- Why doesn't functools.wraps rescue a class decorator that returns a function?`functools.wraps` copies metadata — `__name__`, `__qualname__`, `__doc__`, `__module__` — onto the wrapper and records `__wrapped__`. Metadata is not type-hood. The object is still a function, so type tests, subclassing and pickling fail exactly as before. It arguably makes things worse: tracebacks and `repr` now show the original class name, so the breakage is harder to trace back to the decorator.
- A class decorator forgets its return statement entirely. What is the symptom?The name is rebound to `None`, so the first construction raises `TypeError: 'NoneType' object is not callable`, at a call site that may be far from the decorator. It is the same bug — the return value replaces the name — with a louder failure. Checking that the decorator's result is an instance of `type` before returning it catches this and the wrapper-function case together.
Decorating a class is like swapping the sign on a door. The room behind it is unchanged and the people already inside are fine, but everyone who navigates by the sign now ends up somewhere else entirely.
saying these in an interview costs you the question
- Says a class decorator's return value is ignored
- Claims isinstance returns False rather than raising TypeError
- Thinks functools.wraps makes a wrapper usable as a class
- Believes zero-argument super() breaks after the rebinding
- Assumes pickle only needs the instance dictionary
- Treats a returned proxy instance as equivalent to a subclass