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.
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.
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.”
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
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.
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__.
@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.
- Expecting a
ClassVarto be a constructor argument. It is excluded by design; passing it raises mypy[call-arg]/ pyrightreportCallIssue. Set class state after construction or in__post_init__(via the class, e.g.Product.count += 1). Because it is absent from__init__, aClassVaralso 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
ClassVarordefault_factory.items: list = []raises a runtimeValueErrorfrom the dataclass at class-definition time; pickfield(default_factory=list)for a fresh per-instance list orClassVarfor one deliberately shared list. The same applies todictandset, and since Python 3.11 to any unhashable default. An immutable default such as()or0is fine to write directly because it cannot be mutated in place. - Writing to a
ClassVarthrough an instance.self.count = 5creates 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 ofself.countsee the shadow rather than the class value. Always mutate throughtype(self).countor the class name so every instance observes the change. - Annotating a
ClassVarwith a dataclassfield().field()configures real fields (defaults,repr,compare,kw_only); combining it withClassVaris meaningless because a class variable is not a field, and both checkers reject it. Give theClassVara plain class-body value instead. Likewise, do not wrap aClassVarinInitVaror 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.