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
  • @dataclass escanea los atributos con anotación de tipo (name, email, age) y los considera como campos.
  • age: int = 0 tiene 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 que namedtuple no hace bien.

Nota: Si solo necesitas una tupla inmutable ligera sin métodos, namedtuple o typing.NamedTuple siguen 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 error ValueError: 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 a list() 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 amount y normalizamos currency a 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=True bloquea __setattr__ — cualquier asignación después de la inicialización lanzará un error.
  • Gracias a ser inmutable, un objeto frozen se puede usar como clave en un dict o elemento en un set (porque dataclass genera automáticamente __hash__ cuando frozen=True y eq=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=True solo 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, usa tuple o 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
  • Employee hereda name, age de Person, luego añade salary.
  • 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 TypeError al 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=True genera __lt__, __le__, __gt__, __ge__, comparando por orden de campos (como comparar tuplas).
  • field(compare=False) excluye name de la comparación — solo se compara por price.
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'))
  • asdict convierte recursivamente incluso dataclasses anidados (Address) a dicts anidados — muy útil para devolver JSON a través de una API.
  • astuple es similar pero devuelve tuplas anidadas.

Nota: asdict/astuple hace 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_equality verifica 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_amount verifica que __post_init__ valide correctamente.
  • test_point_is_frozen confirma que frozen=True bloquea la asignación de atributos.

11. Errores comunes y cuándo no usar

  • Olvidar default_factory para valores por defecto mutable: usar = [] o = {} directamente causará error inmediatamente al declarar la clase, debes usar field(default_factory=...).
  • Mezclar campos con y sin valor por defecto en orden incorrecto en la herencia: causa TypeError al cargar el módulo, difícil de depurar si las clases están dispersas en varios archivos.
  • Pensar que frozen=True es 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: dataclasses no está disponible por defecto, tienes que instalar el paquete dataclasses desde PyPI (backport) o usar attrs.

Nota: Si necesitas validación compleja, serialización personalizada o integración profunda con JSON schema, librerías como pydantic suelen ser más potentes que dataclasses puro — 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.

pythondataclassesoopclean-code