En Python, un atributo definido en el cuerpo de una clase puede representar configuración compartida, un registro, un contador, una constante o simplemente un valor por defecto que cada instancia reemplaza. El runtime permite todos esos patrones, pero la ambigüedad puede confundir a lectores, dataclasses y analizadores. typing.ClassVar declara explícitamente que un atributo pertenece a la clase y no debe tratarse como campo de instancia.
Esta guía explica ClassVar en clases normales y dataclasses, herencia, sombreado, caches, registries, contadores, interacción con Final y riesgos del estado mutable compartido.
El problema de la ambigüedad
class Usuario:
total = 0
nombre = ""total parece un contador compartido y nombre parece información por instancia. Sin inicialización y anotaciones claras, las herramientas no distinguen bien la intención.
Declarar ClassVar
from typing import ClassVar
class Usuario:
total: ClassVar[int] = 0
def __init__(self, nombre: str) -> None:
self.nombre = nombre
type(self).total += 1ClassVar indica que total pertenece a la clase. El analizador puede advertir cuando se usa como si fuera un campo individual.
Acceso por clase e instancia
print(Usuario.total)
usuario = Usuario("Ana")
print(usuario.total)Las reglas de lookup permiten leer un atributo de clase desde una instancia. Aun así, prefiere Usuario.total o type(usuario).total cuando quieres expresar estado compartido.
Asignar por instancia crea sombreado
usuario.total = 100En una clase normal, esta asignación puede crear un atributo en la instancia que oculta el valor de clase para ese objeto. El contador compartido sigue en Usuario.total. ClassVar ayuda al checker, pero no cambia el comportamiento de runtime.
ClassVar en dataclasses
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Producto:
impuesto_predeterminado: ClassVar[float] = 0.1
nombre: str
precio: floatLa dataclass no trata impuesto_predeterminado como campo. No aparece en el __init__ generado, no participa como campo en comparaciones y no aparece en la enumeración de fields.
producto = Producto("Teclado", 200.0)Sin ClassVar, el atributo podría convertirse en campo de instancia y modificar el constructor.
Valores mutables compartidos en dataclasses
@dataclass
class Catalogo:
cache: ClassVar[dict[str, object]] = {}
nombre: str = "principal"El diccionario es compartido por todas las instancias. Puede ser intencional, pero necesita política de limpieza, decisiones de concurrencia y pruebas aisladas. ClassVar no vuelve inmutable ni thread-safe al objeto.
Estado mutable compartido
Listas y diccionarios de clase viven más que las instancias y pueden persistir entre peticiones o pruebas.
class Registro:
items: ClassVar[dict[str, type]] = {}
@classmethod
def registrar(cls, nombre: str, tipo: type) -> None:
cls.items[nombre] = tipoUn registry de plugins es un uso razonable. Datos específicos de una petición no lo son. Encapsula mutaciones, ofrece reset y considera sincronización.
ClassVar con classmethod
class Contador:
valor: ClassVar[int] = 0
@classmethod
def incrementar(cls) -> int:
cls.valor += 1
return cls.valorEl classmethod recibe la clase concreta. Las subclases pueden compartir o crear su propio valor según dónde se realice la asignación.
Herencia y configuración por subclase
class Base:
limite: ClassVar[int] = 10
class Premium(Base):
limite = 100Premium define su propio atributo y otras subclases heredan Base.limite. Es útil para configuración polimórfica. Si la redefinición debe prohibirse, considera Final.
Actualizar mediante cls
class Base:
llamadas: ClassVar[int] = 0
@classmethod
def registrar_llamada(cls) -> None:
cls.llamadas += 1Al llamarlo desde una subclase, la operación puede crear un atributo propio en esa subclase. Si el contador debe ser global para toda la jerarquía, actualiza Base.llamadas explícitamente o mueve el estado a otro objeto.
ClassVar y Final
ClassVar significa que el atributo pertenece a la clase. Final significa que no debería redefinirse. Las intenciones pueden coincidir, pero las combinaciones exactas dependen de la versión y del analizador.
from typing import Final
class Protocolo:
VERSION: Final[str] = "1"Para un valor fijo en toda la jerarquía, Final en el cuerpo suele ser suficiente. Para estado compartido configurable, usa ClassVar.
ClassVar y propiedades
Una propiedad representa acceso calculado de instancia; ClassVar describe almacenamiento o configuración de clase.
class Circulo:
pi: ClassVar[float] = 3.141592653589793
def __init__(self, radio: float) -> None:
self.radio = radio
@property
def area(self) -> float:
return self.pi * self.radio ** 2ClassVar en Protocol
Un Protocol puede describir un atributo de clase, aunque el soporte detallado varía entre analizadores.
from typing import Protocol
class Serializavel(Protocol):
formato: ClassVar[str]
def serializar(self) -> bytes: ...El contrato indica que las implementaciones exponen una configuración de clase llamada formato.
Factories y registries
class Conversor:
_formatos: ClassVar[dict[str, type["Conversor"]]] = {}
def __init_subclass__(cls, *, formato: str, **kwargs) -> None:
super().__init_subclass__(**kwargs)
Conversor._formatos[formato] = cls
@classmethod
def crear(cls, formato: str) -> "Conversor":
tipo = cls._formatos[formato]
return tipo()El registry pertenece a la familia de clases, no a cada conversor. Usa una referencia explícita a la base cuando el mapa debe ser único.
Caches de clase
class Parser:
_cache: ClassVar[dict[str, object]] = {}
@classmethod
def compilar(cls, expresion: str) -> object:
if expresion not in cls._cache:
cls._cache[expresion] = compilar_expresion(expresion)
return cls._cache[expresion]Considera límites de memoria, invalidación, concurrencia y si las subclases deben compartir cache. functools.lru_cache o un servicio externo pueden ser opciones mejores.
Contadores de instancias
class Conexion:
abiertas: ClassVar[int] = 0
def __init__(self) -> None:
type(self).abiertas += 1
def cerrar(self) -> None:
type(self).abiertas -= 1La lógica puede fallar con cierres dobles, excepciones, threads o subclases. ClassVar solo describe dónde vive el estado.
ClassVar y slots
__slots__ controla almacenamiento de instancia. Los atributos de clase siguen en el objeto clase. ClassVar documenta la separación, pero no reemplaza slots ni cambia memoria.
ClassVar sin parámetro
configuracion: ClassVar = {}Puede aceptarse, pero un tipo interno explícito mejora el análisis:
configuracion: ClassVar[dict[str, str]] = {}No usar ClassVar para defaults de instancia
@dataclass
class Tarea:
prioridad: ClassVar[int] = 1
titulo: str = ""Si cada tarea necesita prioridad propia, elimina ClassVar:
@dataclass
class Tarea:
titulo: str
prioridad: int = 1Serialización
Los serializadores de dataclasses suelen ignorar ClassVar porque no es field. Las herramientas basadas en vars(instancia) tampoco ven atributos que solo existen en la clase. Incluye configuración compartida explícitamente si debe salir en JSON.
Aislamiento de pruebas
El estado de clase puede filtrarse entre tests. Limpia registries y caches en fixtures o ofrece métodos dedicados:
@classmethod
def limpiar_cache(cls) -> None:
cls._cache.clear()No dependas del orden de ejecución de pruebas.
Concurrencia
ClassVar no vuelve atómicas las operaciones. Contadores, registries y caches compartidos pueden requerir locks, colas, almacenamiento seguro para procesos u otra estrategia.
Cuándo el estado de módulo es más claro
Si el estado no pertenece conceptualmente a la clase, una variable privada de módulo puede ser más simple. Usa ClassVar cuando la configuración o registry forme parte del contrato y pueda especializarse por herencia.
Cuándo preferir composición
Registries, caches y contadores complejos pueden ser objetos independientes inyectados en consumidores. La composición mejora aislamiento y pruebas. ClassVar es cómodo, pero no debe convertirse en almacenamiento global invisible.
Errores comunes
- Usar ClassVar para un campo de dataclass: desaparece del constructor.
- Asignar por instancia: puede crear sombreado.
- Compartir una colección mutable sin intención: las instancias interfieren.
- Ignorar herencia: las subclases pueden compartir o separar estado.
- Esperar seguridad de concurrencia: solo es una anotación.
- Usarlo como global disfrazado: las dependencias son difíciles de probar.
Ejemplo completo: codecs registrados
from typing import ClassVar
class Codec:
_tipos: ClassVar[dict[str, type["Codec"]]] = {}
nombre: ClassVar[str]
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
nombre = getattr(cls, "nombre", None)
if nombre:
Codec._tipos[nombre] = cls
@classmethod
def crear(cls, nombre: str) -> "Codec":
try:
tipo = Codec._tipos[nombre]
except KeyError as error:
raise ValueError(f"codec desconocido: {nombre}") from error
return tipo()
def codificar(self, texto: str) -> bytes:
raise NotImplementedError
class Utf8Codec(Codec):
nombre: ClassVar[str] = "utf-8"
def codificar(self, texto: str) -> bytes:
return texto.encode("utf-8")El mapa es único para la jerarquía y cada subclase publica un nombre de clase. Ninguno es campo de instancia.
Buenas prácticas
Anota el estado compartido. Accede mediante la clase. Encapsula mutaciones en classmethods. Documenta si las subclases comparten o reemplazan valores. Evita colecciones mutables públicas. Ofrece limpieza para tests y trata la concurrencia conscientemente.
Conclusión
typing.ClassVar separa atributos de clase de campos de instancia y es especialmente importante en dataclasses, registries, caches, contadores y configuración de jerarquías. Mejora la claridad estática, pero no cambia las reglas de lookup o mutación de Python.
La documentación oficial de ClassVar en Python define su uso. Combínalo con encapsulación, políticas de herencia, aislamiento de pruebas y sincronización cuando el estado compartido sea mutable.







