typing.Self vs TypeVar for Return Types
Self (PEP 673, Python 3.11) is the clean way to type any method that returns its own instance — fluent builders, __enter__, copy(), alternative constructors. The old approach — a TypeVar bound to the class and threaded through self — still works and is what you need before 3.11, but it is verbose and easy to get subtly wrong so subclasses lose their precise type. Prefer Self; fall back to the bound TypeVar only for older runtimes.
Methods that return self (or a fresh instance of the same class) are everywhere: builder chains, context managers, clone() helpers, and classmethod factories. The hard part is making the return type track the actual subclass, so that RetryBuilder().with_backoff() is still a RetryBuilder and not just its base. This page belongs to Self and NotRequired Types and compares the two ways to express that.
The problem: returning self from a base class
Annotating the return type as the enclosing class name looks correct but is wrong for subclasses. The checker takes you literally: a method returning QueryBuilder returns exactly that, even when called on a subclass.
# Python 3.10, checked with mypy 1.10 / pyright 1.1.370
class QueryBuilder:
def where(self, clause: str) -> "QueryBuilder": # too concrete
return self
class PagedQuery(QueryBuilder):
def limit(self, n: int) -> "PagedQuery":
return self
PagedQuery().where("id > 0").limit(10) # mypy: [attr-defined] "QueryBuilder" has no attribute "limit"
where returns the base type, so .limit() — which only PagedQuery defines — is flagged with [attr-defined] (pyright words the same problem as reportAttributeAccessIssue). Every method in the chain has to preserve the concrete type for fluent APIs to type-check; a single method that hardcodes the base collapses everything downstream of it. You can watch the type widen with reveal_type:
# Python 3.10, mypy 1.10 / pyright 1.1.370
reveal_type(PagedQuery()) # PagedQuery
reveal_type(PagedQuery().where("id > 0")) # QueryBuilder — widened here
The reason is not variance — it is that a bare class name is an ordinary nominal type with no link to type(self). mypy and pyright both resolve -> "QueryBuilder" to the literal class and discard the fact that the receiver was a PagedQuery. The quotes are only a forward reference so the name can appear before the class body is complete; from __future__ import annotations (PEP 563) makes every annotation a string automatically, but it changes nothing here — the annotation still denotes the base class, just lazily.
The tempting fix — re-annotating the method in every subclass to return that subclass — does not scale and quickly turns into busywork: you would have to override where, select, and every other chainable method in each subclass purely to restate the return type, and a return-type override that widens rather than narrows is itself an LSP-style error. The same trap hits __enter__ returning the base, copy()/clone() helpers, and @classmethod factories that build the wrong static type. What you want is a return annotation that means “whatever concrete class this method was called on,” and Python has two ways to spell that.
This shows up constantly in real APIs. Type __enter__ as -> QueryBuilder and with PagedQuery() as q: binds q to QueryBuilder, so q.limit(10) inside the block is unavailable; type a clone(self) -> QueryBuilder helper the same way and every subclass silently downgrades its own copies to the base. The damage is not confined to CI, either — editors read the same annotations, so autocomplete after a widened call offers only base-class members and “go to definition” lands on the wrong method. A subtle variant appears with Optional-returning helpers such as def find(self) -> "QueryBuilder | None", where the None branch is fine but the non-None branch is over-widened. Every one of these is the identical root cause: a nominal class name has no connection to type(self), and only an annotation that refers back to the receiver can restore it.
The old fix: a TypeVar bound to the class
Before PEP 673 the idiom was a TypeVar bound to the class, threaded through self. It works, but every such method must declare self: T and return T, which is noisy and easy to forget on one method in a long chain.
# Python 3.10, checked with mypy 1.10 / pyright 1.1.370
from typing import TypeVar
TBuilder = TypeVar("TBuilder", bound="QueryBuilder")
class QueryBuilder:
def where(self: TBuilder, clause: str) -> TBuilder:
return self
class PagedQuery(QueryBuilder):
def limit(self, n: int) -> "PagedQuery":
return self
PagedQuery().where("id > 0").limit(10) # now OK — where returns PagedQuery
This relies on generics and TypeVar machinery and a forward-referenced bound. The mechanism is a per-call solve: when you write PagedQuery().where(...), the checker unifies the type of the receiver (PagedQuery) with the declared self: TBuilder, binds TBuilder = PagedQuery, and substitutes that into the return -> TBuilder. The bound="QueryBuilder" clause is what keeps the pattern safe — it guarantees the solved type is always a QueryBuilder subclass, so the method body may use base-class attributes. Writing the bound as a string is only a forward reference (the class is not finished when the TypeVar is defined); from __future__ import annotations does not remove the need for it, because the TypeVar call runs at runtime, not in an annotation.
The fragility is that the type link exists only where you spell self: TBuilder and -> TBuilder together. Miss the self: TBuilder annotation on one method and that method silently returns the bare base type with no warning, collapsing the chain at exactly that step; annotate self: TBuilder but forget to return TBuilder and you get an [return-value] error instead. For a @classmethod the receiver is the class, so the same idea becomes cls: type[TBuilder] — a further variation to remember. The pattern remains the correct choice on runtimes older than 3.11 where you cannot even install typing_extensions, and it is genuinely needed when the returned type is a different type parameter than the receiver (which Self cannot express); PEP 696 TypeVar defaults can reduce some of that boilerplate. For the ordinary “returns its own instance” case, though, every one of these footguns disappears with Self.
The clean fix: Self
Self says “the type of the current instance” without any TypeVar. It reads clearly, needs no per-method self annotation, and does the right thing in subclasses automatically.
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Self
class QueryBuilder:
def where(self, clause: str) -> Self:
return self
class PagedQuery(QueryBuilder):
def limit(self, n: int) -> Self:
return self
reveal_type(PagedQuery().where("id > 0")) # PagedQuery
PagedQuery().where("id > 0").limit(10) # OK
Self also shines on alternative constructors and __enter__:
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Self
class Session:
@classmethod
def open(cls, url: str) -> Self: # subclass factory keeps its type
return cls()
def __enter__(self) -> Self:
return self
On Python 3.9–3.10, import Self from typing_extensions for the same behaviour without upgrading the runtime.
Semantically, Self is an implicitly bound TypeVar on self — the checker solves it per call site exactly as it does the explicit TBuilder above — so mypy and pyright treat a correct Self version and a correct bound-TypeVar version as equivalent at the call site. The win is purely in what you write: no TypeVar object to declare, no self: T to repeat on every method, and no forward-referenced bound to keep in sync when the class is renamed. Because there is nothing per-method to omit, the “collapsed chain” bug that plagues the TypeVar idiom simply cannot occur. Both checkers also propagate Self through nested annotations (list[Self], dict[str, Self], Iterator[Self]) and through async def methods, and it is the annotation a Protocol uses to describe a self-returning method structurally.
Two boundaries are worth stating plainly. First, Self describes only the receiver’s type; if a method must return a value whose type is an unrelated type parameter, you still need an explicit TypeVar, because Self has nothing to bind it to. Second, a method annotated -> Self must return self or type(self)(...) — returning a freshly built base instance (return QueryBuilder()) is [return-value], since a base object is not guaranteed to be the caller’s concrete class. Within those limits, prefer Self on Python 3.11+ (and via typing_extensions down to 3.8), and reach back for the bound TypeVar only when the runtime is too old for even the backport, or when the returned type genuinely is not the receiver.
Overrides need no special care. When a subclass overrides a Self-returning method, it simply annotates the override -> Self again (or inherits the signature unchanged), and both checkers verify that the override still returns the instance type — you never restate a base class name. Contrast this with the TypeVar idiom, where an override that forgets self: TBuilder reintroduces the collapse. This is also why Self composes cleanly with mixins and multiple inheritance: each concrete class resolves Self to itself regardless of where in the MRO the method is defined, so a chainable mixin method written once serves every class that mixes it in.
Self is a typing-only construct: at runtime your method still just returns whatever object it returns. If a method annotated -> Self actually returns a *different* class's instance, Python runs it happily while mypy reports [return-value]. The annotation constrains the checker, not the interpreter.
Common mistakes
Four slips account for almost every self-return type bug. Each rung below pairs the mistake with the fix and the error code the checker emits.
- Returning a hardcoded base class name: subclass-only methods later in the chain fail with
[attr-defined](pyright:reportAttributeAccessIssue). UseSelf(or the bound TypeVar) so the concrete type survives every step. - Returning a new base instance under
Self:return QueryBuilder()in a method typed-> Selfis[return-value](Incompatible return value type), because a base instance is not necessarilySelf. Returnselfortype(self)(...), which constructs the runtime class of the receiver. - Forgetting
self: Ton one method of the TypeVar pattern: that method silently returns the base type and breaks the chain, and no diagnostic points at the omission — the error only surfaces later as[attr-defined]on the next call. This fragility is the main reason to preferSelf. - Using
Selfoutside a class body: it is only meaningful inside a class; at module scope mypy reports[misc](“Selfis only allowed in annotations within a class definition”) and pyright a comparablereportGeneralTypeIssues. For standalone functions use an explicitTypeVar. - Widening a return type in an override: re-annotating a subclass method to return the base defeats the whole point and is itself an LSP-style narrowing violation. Let
Selfhandle the subclass type rather than restating it.
FAQ
Is Self just shorthand for a bound TypeVar?
Semantically it behaves like an implicitly bound TypeVar on self, but it removes the boilerplate: no TypeVar declaration, no self: T on every method, no forward-referenced bound. Both mypy and pyright treat a correct Self and a correct bound-TypeVar version as equivalent at the call site. The difference that matters in practice is failure mode: the TypeVar version has a per-method annotation you can forget, whereas Self has nothing to omit, so a whole class of “silently widened” bugs cannot occur.
Can I use Self in a classmethod or a nested type like list[Self]?
Yes to both. In a classmethod, -> Self means “an instance of the actual class the method was called on,” which is exactly what you want for factories; note the receiver there is typed cls: type[Self], not cls: Self. You can also nest it — def batch(self) -> list[Self] types a method returning a list of same-typed instances, and the same works inside dict[str, Self], Iterator[Self], or tuple[Self, Self].
Do I need from __future__ import annotations for either pattern?
No. Self is an ordinary name that works with or without PEP 563. The bound TypeVar uses a string only for its bound= forward reference, and that string is evaluated when the TypeVar() call runs at import time — from __future__ import annotations affects only annotation expressions, not that runtime call, so it neither helps nor hurts.
Which should I pick for a library that supports Python 3.9?
Import Self from typing_extensions; it gives the 3.11 behaviour on 3.8+ and is recognised identically by both checkers. Only fall back to the explicit bound TypeVar if you cannot add typing_extensions as a dependency, or if the returned type is genuinely a different type parameter than the receiver.