dataclasses: clases de datos compactas en Python
1. Descripción general
Cuando escribes clases solo para contener datos (como Order, Customer, Point…), normalmente tienes que escribir __init__, __repr__, __eq__ manualmente — todo es código repetitivo. El módulo dataclasses (disponible desde Python 3.7) te ayuda a generar automáticamente estos métodos con solo un decorador. Después de leer este artículo, sabrás cómo usar @dataclass para escribir clases de datos compactas, validar datos con __post_init__, crear clases inmutables y convertir a dict/JSON.
2. ¿Qué es una dataclass básica?
@dataclass es un decorador que lee las anotaciones de tipo en la clase y genera automáticamente __init__, __repr__, __eq__ basándose en esos campos.
from dataclasses import dataclass
@dataclass
class Customer:
name: str
email: str
age: int = 0
@dataclassescanea los atributos con anotación de tipo (name,email,age) y los considera como campos.age: int = 0tiene un valor por defecto, como un parámetro con valor por defecto en una función.- No necesitas escribir
__init__— se genera automáticamente en el orden correcto de campos.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)
__repr__ se genera automáticamente para mostrar el nombre completo del campo y su valor, muy útil para depuración — normalmente una clase no tiene esto por defecto.
3. Comparación con clases normales y namedtuple
Escribir manualmente una clase equivalente sería mucho más largo:
class CustomerPlain:
def __init__(self, name, email, age=0):
self.name = name
self.email = email
self.age = age
def __repr__(self):
return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"
def __eq__(self, other):
if not isinstance(other, CustomerPlain):
return NotImplemented
return (self.name, self.email, self.age) == (other.name, other.email, other.age)
@dataclass te ahorra escribir toda esta parte. Comparado con namedtuple, dataclass es diferente en que:
- Los objetos son mutables por defecto (puedes modificar
c1.age = 20), namedtuple no lo es. - Soporta anotaciones de tipo explícitas, fácil integración con verificadores de tipo como
mypy. - Soporta
__post_init__, valores por defecto complejos, herencia — cosas quenamedtupleno hace bien.
Nota: Si solo necesitas una tupla inmutable ligera sin métodos,
namedtupleotyping.NamedTuplesiguen siendo opciones más compactas.
4. field() y valores por defecto mutable
Un error muy común: usar list/dict como valor por defecto directamente.
from dataclasses import dataclass, field
@dataclass
class Order:
order_id: str
items: list[str] = field(default_factory=list)
- Si escribes
items: list[str] = [], Python mostrará un errorValueError: mutable default ... is not allowed— porque la lista por defecto será compartida entre todas las instancias, causando bugs difíciles de encontrar. field(default_factory=list)le pide a dataclass que llame alist()nueva para cada instancia.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")
print(o1.items, o2.items)
['Sách'] []
Los dos objetos tienen items independientes, como se esperaba.
5. Validar datos con __post_init__
El __init__ generado por dataclass no te permite insertar lógica de validación directamente — pero hay un hook __post_init__ que se ejecuta justo después de que __init__ termina.
from dataclasses import dataclass
@dataclass
class PaymentRequest:
amount: float
currency: str = "VND"
def __post_init__(self):
if self.amount <= 0:
raise ValueError("amount debe ser mayor que 0")
self.currency = self.currency.upper()
__post_init__se llama automáticamente después de que todos los campos se han asignado.- Aquí validamos
amounty normalizamoscurrencya mayúsculas.
try:
PaymentRequest(amount=-100)
except ValueError as e:
print(e)
amount debe ser mayor que 0
6. Clases inmutables con frozen=True
Si quieres que un objeto no se pueda modificar después de su creación (como un record en Java), usa frozen=True.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
frozen=Truebloquea__setattr__— cualquier asignación después de la inicialización lanzará un error.- Gracias a ser inmutable, un objeto
frozense puede usar como clave en undicto elemento en unset(porque dataclass genera automáticamente__hash__cuandofrozen=Trueyeq=True).
p = Point(1.0, 2.0)
try:
p.x = 5.0
except Exception as e:
print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'
Nota:
frozen=Truesolo bloquea la asignación de atributos de nivel 1. Si un campo es una lista/dict (mutable), su contenido interno aún se puede modificar. Para una verdadera inmutabilidad, usatupleo un tipo inmutable para ese campo.
7. Herencia con dataclass
Dataclass soporta herencia normal como las clases de Python, los campos de la clase derivada se añaden después de los campos de la clase base.
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
@dataclass
class Employee(Person):
salary: float = 0.0
Employeeheredaname,agedePerson, luego añadesalary.- El orden de parámetros en
__init__generado es:name, age, salary— se mantiene el orden de declaración.
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)
Nota: Los campos sin valor por defecto deben estar antes de los campos con valor por defecto en toda la cadena de herencia. Si una clase base tiene un campo con valor por defecto pero la clase derivada añade un campo sin valor por defecto, Python lanzará un
TypeErroral definir la clase.
8. Comparación y ordenamiento con order=True
Por defecto, dataclass solo genera __eq__. Si quieres comparaciones <, >, <=, usa order=True.
from dataclasses import dataclass
@dataclass(order=True)
class Product:
price: float
name: str = field(compare=False)
order=Truegenera__lt__,__le__,__gt__,__ge__, comparando por orden de campos (como comparar tuplas).field(compare=False)excluyenamede la comparación — solo se compara porprice.
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]
sorted() usa __lt__ generado por dataclass, no necesitas escribir key=lambda p: p.price manualmente.
9. Convertir a dict y tuple
dataclasses proporciona funciones de utilidad asdict y astuple para serializar objetos.
from dataclasses import dataclass, asdict, astuple
@dataclass
class Address:
city: str
country: str
@dataclass
class Customer2:
name: str
address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
asdictconvierte recursivamente incluso dataclasses anidados (Address) a dicts anidados — muy útil para devolver JSON a través de una API.astuplees similar pero devuelve tuplas anidadas.
Nota:
asdict/astuplehace una copia profunda de todos los datos. Con objetos grandes o que contienen campos que no se pueden copiar (file handle, connection…), llamar esta función será lento o causará un error.
10. Escribir tests para dataclass
Porque __eq__ se genera automáticamente basándose en los valores de los campos, probar comparaciones de objetos es muy natural con pytest.
import pytest
from dataclasses import FrozenInstanceError
def test_customer_equality():
c1 = Customer("An", "an@example.com", 20)
c2 = Customer("An", "an@example.com", 20)
assert c1 == c2
def test_payment_request_rejects_non_positive_amount():
with pytest.raises(ValueError):
PaymentRequest(amount=0)
def test_point_is_frozen():
p = Point(1.0, 2.0)
with pytest.raises(FrozenInstanceError):
p.x = 10.0
test_customer_equalityverifica que dos objetos de diferentes instancias pero con los mismos valores de campo sean==— correcto gracias a__eq__auto generado.test_payment_request_rejects_non_positive_amountverifica que__post_init__valide correctamente.test_point_is_frozenconfirma quefrozen=Truebloquea la asignación de atributos.
11. Errores comunes y cuándo no usar
- Olvidar
default_factorypara valores por defecto mutable: usar= []o= {}directamente causará error inmediatamente al declarar la clase, debes usarfield(default_factory=...). - Mezclar campos con y sin valor por defecto en orden incorrecto en la herencia: causa
TypeErroral cargar el módulo, difícil de depurar si las clases están dispersas en varios archivos. - Pensar que
frozen=Truees deep immutable: los campos de tipo list/dict aún pueden tener su contenido modificado, solo no se puede reasignar el atributo. - Usar dataclass para lógica de negocio compleja: si una clase tiene muchos métodos de procesamiento, relaciones complejas con otros objetos, dataclass solo ayuda con la inicialización/comparación — aún tienes que escribir la lógica por ti mismo, no abuses para “fingir” que una clase es más simple de lo que realmente es.
- Necesita compatibilidad con Python < 3.7:
dataclassesno está disponible por defecto, tienes que instalar el paquetedataclassesdesde PyPI (backport) o usarattrs.
Nota: Si necesitas validación compleja, serialización personalizada o integración profunda con JSON schema, librerías como
pydanticsuelen ser más potentes quedataclassespuro — vale la pena considerarlas para proyectos grandes.
12. Conclusión
dataclasses te ayuda a eliminar la mayoría del boilerplate al escribir clases que contienen datos: genera automáticamente __init__, __repr__, __eq__, soporta validación a través de __post_init__, inmutabilidad con frozen=True y serialización fácil con asdict/astuple. Para clases de datos simples a medianas, esta es una opción mucho más compacta y legible que escribir manualmente. Cuando tienes necesidades de validación o serialización más complejas, puedes considerar usar pydantic o attrs como alternativa.