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.
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
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.”
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
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.
@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
- 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__. - Using a bare mutable default instead of
ClassVarordefault_factory.items: list = []raises a runtimeValueErrorfrom the dataclass; pickfield(default_factory=list)for per-instance orClassVarfor shared. - Writing to a
ClassVarthrough an instance.self.count = 5creates an instance attribute that shadows the shared one; mypy flags the assignment as[misc]. Usetype(self).countor the class name. - Annotating a
ClassVarwith a dataclassfield().field()applies only to real fields; combining it withClassVaris meaningless and rejected — give theClassVara plain class-body value.
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.