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

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.

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

  • 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__.
  • Using a bare mutable default instead of ClassVar or default_factory. items: list = [] raises a runtime ValueError from the dataclass; pick field(default_factory=list) for per-instance or ClassVar for shared.
  • Writing to a ClassVar through an instance. self.count = 5 creates an instance attribute that shadows the shared one; mypy flags the assignment as [misc]. Use type(self).count or the class name.
  • Annotating a ClassVar with a dataclass field(). field() applies only to real fields; combining it with ClassVar is meaningless and rejected — give the ClassVar a 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.

Back to Final and ClassVar Annotations