ClassVar vs Instance Attributes in Dataclasses

TL;DR — Annotate a dataclass attribute with ClassVar and the @dataclass decorator excludes it from the generated __init__, leaves it out of fields(), and keeps its class-body value as state shared by every instance. Without ClassVar, an annotated attribute with a mutable default becomes a per-field default that is silently shared across instances — the classic footgun ClassVar (and field(default_factory=...)) exists to prevent.

The @dataclass decorator reads your class-body annotations to decide what goes into __init__. That single rule is the whole story: a bare annotation is a field (a constructor parameter); a ClassVar annotation is not. Getting this right is what separates a per-instance value from genuinely shared class state, and it is where a registry list or an instances counter belongs.

Fields versus ClassVar in a dataclass Bare annotations become instance fields and constructor parameters, while ClassVar annotations are shared across instances and left out of the generated init. @dataclass body sku: str price: float count: ClassVar[int] registry: ClassVar[list] __init__ params sku, price per instance Shared on class count, registry not a field
Bare annotations become constructor parameters; ClassVar annotations stay on the class as shared state.

What ClassVar changes in a dataclass

Consider a product type that needs both per-instance data and two pieces of class-level bookkeeping: a live instance counter and a registry of every product created. Those two belong on the class, so they get ClassVar.

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from dataclasses import dataclass, field
from typing import ClassVar

@dataclass
class Product:
    sku: str
    price: float
    tags: list[str] = field(default_factory=list)   # per-instance mutable default
    count: ClassVar[int] = 0                         # shared counter
    registry: ClassVar[list["Product"]] = []        # shared registry

    def __post_init__(self) -> None:
        Product.count += 1
        Product.registry.append(self)

item = Product(sku="A-100", price=9.99)             # count and registry are NOT params

Product(sku=..., price=..., tags=...) accepts exactly the three fields. count and registry never appear in __init__, do not show up in dataclasses.fields(Product), and are read and written through the class. Passing them as keyword arguments is a call error.

# Python 3.11+, mypy 1.10 / pyright 1.1.370
Product(sku="B", price=1.0, count=5)   # mypy: Unexpected keyword argument "count"  [call-arg]
                                       # pyright: reportCallIssue

How does the decorator actually recognise ClassVar? At class-definition time, @dataclass walks __annotations__ and asks, per name, whether the annotation is typing.ClassVar or a subscription of it. This detection is partly textual, and that has a real consequence under from __future__ import annotations (PEP 563), where every annotation is stored as a string. The dataclass machinery then matches the string against the names ClassVar and typing.ClassVar. Import typing under an alias — import typing as t; count: t.ClassVar[int] = 0 — and the string "t.ClassVar[int]" may not be recognised, so the decorator silently treats count as an ordinary field. The safe habit is to import ClassVar by its plain name from typing and use it unqualified.

A ClassVar counter is shared state across constructions Each Product created advances the single class-level count, which every instance reads through the class. Product.count (one value on the class) Product("A") count → 1 Product("B") count → 2 Product("C") count → 3 every instance reads the same shared count via the class
A ClassVar counter is a single value on the class that each construction advances.

ClassVar itself is old and stable: it arrived with the variable-annotation syntax of PEP 526 in Python 3.6, lives in typing, and needs no typing_extensions backport on any supported interpreter. It is purely a typing construct — typing.ClassVar is not subscriptable into a runtime container and carries no methods; its only jobs are to signal “class-level” to type checkers and, inside a dataclass specifically, to steer the code generator. Outside a dataclass, ClassVar still documents intent to mypy and pyright (they forbid assigning to it through an instance), but it has no generated __init__ to influence, so the dramatic behavioural effect is a dataclass-only phenomenon.

A ClassVar does not require a default value — count: ClassVar[int] with no assignment is a legal type-only declaration (the checker knows the class-level type, though at runtime the attribute simply does not exist until set). But because it is not a field, the dataclass will never fill it in for you, so any ClassVar you intend to read must be given a class-body value or assigned before use. Tools that iterate dataclasses.fields()asdict(), astuple(), the generated __eq__ and __repr__ — all skip ClassVar attributes, which is exactly why bookkeeping state belongs there rather than in a field. If you need to introspect the annotation yourself, typing.get_type_hints(Product, include_extras=True) still returns the ClassVar[...] wrapper, and typing.get_origin() on it yields typing.ClassVar, so you can distinguish shared attributes from real fields programmatically without relying on the fragile string match.

The shared-mutable-default pitfall it prevents

Here is why the distinction is not academic. Drop the ClassVar and the annotation becomes a field — but a bare mutable default like registry: list = [] is rejected outright by the dataclass machinery precisely because the same list object would be shared by every instance:

# Python 3.11+, runtime
from dataclasses import dataclass

@dataclass
class Basket:
    items: list[str] = []    # ValueError: mutable default <class 'list'>
                             # for field items is not allowed: use default_factory

So a dataclass field forces you to choose: field(default_factory=list) for a fresh per-instance list, or ClassVar for a single deliberately-shared list. The bug the ValueError guards against — every Basket mutating one global list — is exactly the accidental sharing that ClassVar makes intentional and explicit when you actually want it. If you reach for a class-level container, ClassVar says “yes, I mean everyone to share this.”

Aliased default versus default_factory A bare mutable default would alias a single list across every instance, whereas default_factory constructs a fresh list per instance. bare default (rejected) obj A obj B obj C one [] list ValueError at class definition default_factory=list obj A obj B obj C [] A [] B [] C one fresh list per instance
A bare mutable default would alias one list across instances; default_factory builds a fresh one each time.

The mutable-default check fires at class-definition time (when @dataclass runs), not at instantiation, so you learn about the mistake the moment the module imports. Its exact rule has shifted across versions: historically the dataclass rejected defaults that were instances of list, dict, or set; since Python 3.11 the test is broader — it rejects any default whose type is unhashable (type(default).__hash__ is None). That closes a gap for custom mutable classes but is not total: a mutable class that still defines __hash__ slips past the guard, so field(default_factory=...) remains the disciplined choice for any mutable per-instance default. ClassVar sidesteps the check entirely because a class variable is shared by design — the dataclass never treats it as a field, so no default-sharing warning applies.

ClassVar vs Final in a dataclass

ClassVar answers “who owns this attribute?”; Final answers “can it be rebound?” A counter is a ClassVar you mutate on purpose, so it is emphatically not Final. A shared constant you never change is naturally both — spelled ClassVar[Final[int]]. And a per-instance identifier that should be set once uses Final alone, staying a normal field:

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from dataclasses import dataclass
from typing import ClassVar, Final

@dataclass
class Session:
    token: Final[str]                          # per-instance, set once → still a field
    protocol: ClassVar[Final[int]] = 3         # shared AND immutable
    open_sessions: ClassVar[int] = 0           # shared, mutable counter
ClassVar and Final are independent axes Ownership (class versus instance) is orthogonal to mutability (Final versus rebindable), giving four combinations that stack cleanly. ownership × mutability rebindable Final instance class plain field price: float Final field (set once) token: Final[str] shared, mutable count: ClassVar[int] shared, immutable ClassVar[Final[int]]
Ownership and mutability are orthogonal: the four cells stack into distinct, meaningful declarations.

Note that a bare Final assigned in a class body is, per PEP 591, already inferred by type checkers as class-level; the reason token: Final[str] above still becomes a per-instance field is that @dataclass reinterprets an annotated name with no class-body value as a constructor parameter. So inside a dataclass the field-versus-classvar decision is driven by ClassVar, and Final layers immutability on top of whichever ownership you chose. Violating a Final — reassigning session.token = "x" later — is flagged by mypy as “Cannot assign to final attribute” and by pyright as reportGeneralTypeIssues; like all typing, it is a static guarantee, not a runtime lock. Final was introduced later than ClassVar, in PEP 591 with Python 3.8, so on 3.7 and earlier you import it from typing_extensions; the two spellings are interchangeable to both checkers.

The broader comparison lives in the Final and ClassVar overview and the typing.Final for constants guide; the short version is that the two annotations are independent axes and often stack.

Analyzer behavior

Both mypy and pyright special-case ClassVar inside dataclasses natively, so no plugin is required. mypy rejects a ClassVar passed to the constructor with [call-arg] and an attempt to set a class variable through an instance with [misc]. pyright reports the same situations under reportCallIssue and reportGeneralTypeIssues. Because the rules are on by default, they hold even before you turn on stricter mypy settings. Values inside a ClassVar can themselves be constrained with Literal and TypedDict when the shared value must be one of a fixed set.

ClassVar misuse to diagnostic mapping Passing a ClassVar to the constructor and setting it through an instance each produce a specific mypy error code and pyright rule. situation mypy pyright Product(count=5) [call-arg] reportCallIssue self.count = 5 [misc] reportGeneral TypeIssues both diagnostics are on by default — no strict mode needed
Each misuse maps to a specific mypy code and pyright rule, both enabled by default.

It helps to place ClassVar beside its neighbour dataclasses.InitVar, which people sometimes confuse. An InitVar[T] attribute is a constructor parameter and is passed to __post_init__, but it is excluded from fields() and never stored as an attribute — it is init-only. A ClassVar is the opposite on every axis: not a constructor parameter, not passed to __post_init__, and stored once on the class rather than per instance. So the three annotations partition cleanly — a bare annotation is a stored per-instance field, InitVar is a transient constructor input, and ClassVar is shared class state. Remember too the from __future__ import annotations caveat from earlier: because that mode stringifies annotations, the analyzers still resolve ClassVar correctly (they parse the string), but the runtime dataclass relies on the same textual match, so an aliased or unusual spelling can desync the checker’s view from the actual generated __init__.

Runtime vs static analysis At runtime, @dataclass genuinely inspects ClassVar and leaves those attributes out of __init__ and fields() — so this is one of the rare cases where the annotation has a real runtime effect. What is still not enforced is immutability: writing instance.count = 5 creates a shadowing instance attribute at runtime even though the checker flags it. Mutate shared state through the class (Product.count += 1), not the instance.

Common mistakes

Most of these reduce to one question — “should every instance share this value, or should each get its own?” — and one follow-up about mutability. Answer those two and the right annotation falls out.

Choosing the right annotation for a dataclass attribute Deciding whether the value is shared, then whether its default is mutable, then whether it may be rebound, selects field, default_factory, ClassVar, or Final. shared by all instances? yes no ClassVar[...] mutable default? (list/dict/set) yes no field(default_factory=...) plain field add Final if never rebound add Final[...] to any of these to lock it after first set
Shared-or-not decides ClassVar; a mutable per-instance default decides default_factory; Final layers on top.
  • Expecting a ClassVar to be a constructor argument. It is excluded by design; passing it raises mypy [call-arg] / pyright reportCallIssue. Set class state after construction or in __post_init__ (via the class, e.g. Product.count += 1). Because it is absent from __init__, a ClassVar also plays no part in the generated __eq__ or __repr__, so two instances comparing equal never depends on shared state — another reason bookkeeping belongs here.
  • Using a bare mutable default instead of ClassVar or default_factory. items: list = [] raises a runtime ValueError from the dataclass at class-definition time; pick field(default_factory=list) for a fresh per-instance list or ClassVar for one deliberately shared list. The same applies to dict and set, and since Python 3.11 to any unhashable default. An immutable default such as () or 0 is fine to write directly because it cannot be mutated in place.
  • Writing to a ClassVar through an instance. self.count = 5 creates an instance attribute that shadows the shared one for that object only; mypy flags the assignment as [misc], but at runtime it silently succeeds and future reads of self.count see the shadow rather than the class value. Always mutate through type(self).count or the class name so every instance observes the change.
  • Annotating a ClassVar with a dataclass field(). field() configures real fields (defaults, repr, compare, kw_only); combining it with ClassVar is meaningless because a class variable is not a field, and both checkers reject it. Give the ClassVar a plain class-body value instead. Likewise, do not wrap a ClassVar in InitVar or vice versa — they describe opposite lifetimes and cannot coexist on one attribute.

FAQ

Does ClassVar affect dataclasses.fields()? Yes. A ClassVar attribute is excluded from fields() entirely, so tools that iterate fields — serializers, asdict(), comparison — never see it. That is usually what you want for bookkeeping state that is not part of the record’s identity.

Can I give a ClassVar a mutable default directly? Yes — the dataclass mutable-default check does not apply to ClassVar, because sharing is the intent. registry: ClassVar[list] = [] is legal and the list is shared across instances, which is precisely the point.

Back to Final and ClassVar Annotations