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.

# 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]. Every method in the chain has to preserve the concrete type for fluent APIs to type-check.

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. Miss the self: TBuilder annotation on one method and the type collapses again with no obvious warning.

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.

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

  • Returning a hardcoded base class name: subclass-only methods later in the chain fail with [attr-defined]. Use Self (or the bound TypeVar) so the concrete type survives.
  • 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)(...).
  • Forgetting self: T on one method of the TypeVar pattern: that method silently returns the base type and breaks the chain. 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]. For standalone functions use an explicit TypeVar.

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.

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. You can also nest it — def batch(self) -> list[Self] types a method returning a list of same-typed instances.

Back to Self and NotRequired Types