typing.Self vs TypeVar for Return Types

TL;DR

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.

Return-type tracking with Self versus a hardcoded base Calling a chained method on a subclass returns the subclass type when Self is used, but collapses to the base type when the base class is named directly. QueryBuilder where(...) -> Self returns Self PagedQuery().where() -> PagedQuery returns QueryBuilder PagedQuery().where() -> QueryBuilder (lost)
Return Self and the subclass type survives the chain; return the base class and it collapses.

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.

Base return type collapses the method chain Calling where() typed to return QueryBuilder widens the inferred type on a PagedQuery, so the following limit() call is reported as an attribute error. Hardcoded base return type collapses the chain PagedQuery() PagedQuery .where("id > 0") -> QueryBuilder inferred: QueryBuilder (widened) .limit(10) ✗ not found mypy: "QueryBuilder" has no attribute "limit" [attr-defined]
Once where() is typed to return the base QueryBuilder, the subclass method .limit() is unreachable to the checker.
# 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.

Threading a bound TypeVar through self and the return TBuilder bound to QueryBuilder is written on both the self parameter and the return type; calling on a PagedQuery binds TBuilder to PagedQuery. TBuilder = TypeVar(bound="QueryBuilder") def where(self: TBuilder, ...) binds to the receiver -> TBuilder returns the same type PagedQuery().where(...) solves TBuilder = PagedQuery
The bound TypeVar must appear on both self and the return; the call site solves it to PagedQuery. Omit it on one method and the link breaks.
# 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.

Bound TypeVar versus Self feature matrix Across declaration overhead, per-method self annotation, classmethods, context managers, readability, and minimum Python, Self needs less to express the same behaviour. concern bound TypeVar Self (PEP 673) TypeVar declaration one needed none self: T on each method required not needed classmethod factory cls: type[T] -> Self __enter__ / copy() works works readability verbose clear minimum Python any 3.x 3.11*
Both express "returns its own instance", but Self needs no declaration and no per-method annotation. *On 3.8–3.10, import Self from typing_extensions.
# 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.

Runtime vs static analysis 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.

Self return-type mistakes and their fixes Four rungs pair a mistake with its correction: hardcoded base name, returning a new base instance, forgetting the self annotation, and using Self at module scope. mistake fix -> "QueryBuilder" (base name) [attr-defined] downstream -> Self return QueryBuilder() under Self [return-value] return self or type(self)(...) forgot self: T on one method silent chain collapse use Self — nothing to forget Self at module scope [misc] explicit TypeVar in a free function
The four recurring self-return mistakes, each with its correction and the diagnostic that flags it.
  • Returning a hardcoded base class name: subclass-only methods later in the chain fail with [attr-defined] (pyright: reportAttributeAccessIssue). Use Self (or the bound TypeVar) so the concrete type survives every step.
  • Returning a new base instance under Self: return QueryBuilder() in a method typed -> Self is [return-value] (Incompatible return value type), because a base instance is not necessarily Self. Return self or type(self)(...), which constructs the runtime class of the receiver.
  • Forgetting self: T on 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 prefer Self.
  • Using Self outside a class body: it is only meaningful inside a class; at module scope mypy reports [misc] (“Self is only allowed in annotations within a class definition”) and pyright a comparable reportGeneralTypeIssues. For standalone functions use an explicit TypeVar.
  • 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 Self handle 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.

Back to Self and NotRequired Types