Dataclasses en Python: Una guía completa del decorador @dataclass
Updated on
Escribir clases en Python a menudo implica código repetitivo (boilerplate). Defines __init__ para inicializar atributos, __repr__ para una salida legible, __eq__ para comparaciones y, a veces, __hash__ para poder usar hashing. Esta implementación manual se vuelve tediosa para clases que solo contienen datos, especialmente al gestionar objetos de configuración, respuestas de APIs o registros de base de datos.
Python 3.7 introdujo las dataclasses mediante la PEP 557, automatizando este boilerplate sin perder la flexibilidad de las clases normales. El decorador @dataclass genera métodos especiales automáticamente basándose en anotaciones de tipo, reduciendo el código de docenas de líneas a solo unas pocas. Esta guía muestra cómo aprovechar dataclasses para obtener código Python más limpio y mantenible.
Por qué existen las dataclasses: resolver el problema del boilerplate
Las clases tradicionales de Python requieren definir explícitamente métodos para operaciones comunes. Considera esta clase estándar para almacenar datos de usuario:
class User:
def __init__(self, name, email, age):
self.name = name
self.email = email
self.age = age
def __repr__(self):
return f"User(name={self.name!r}, email={self.email!r}, age={self.age!r})"
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return (self.name, self.email, self.age) == (other.name, other.email, other.age)Con dataclasses, esto se reduce a:
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
age: intEl decorador genera __init__, __repr__ y __eq__ automáticamente a partir de las anotaciones de tipo. Esto elimina más de 15 líneas de boilerplate manteniendo la misma funcionalidad.
Sintaxis básica de @dataclass
La dataclass más simple solo requiere anotaciones de tipo para los campos:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int
product = Product("Laptop", 999.99, 5)
print(product) # Product(name='Laptop', price=999.99, quantity=5)
product2 = Product("Laptop", 999.99, 5)
print(product == product2) # TrueEl decorador acepta parámetros para personalizar el comportamiento:
@dataclass(
init=True, # Genera __init__ (por defecto: True)
repr=True, # Genera __repr__ (por defecto: True)
eq=True, # Genera __eq__ (por defecto: True)
order=False, # Genera métodos de comparación (por defecto: False)
frozen=False, # Hace las instancias inmutables (por defecto: False)
unsafe_hash=False # Genera __hash__ (por defecto: False)
)
class Config:
host: str
port: intTipos de campo y valores por defecto
Las dataclasses admiten valores por defecto en campos. Los campos sin valores por defecto deben aparecer antes que los campos con valores por defecto:
from dataclasses import dataclass
@dataclass
class Server:
host: str
port: int = 8080
protocol: str = "http"
server1 = Server("localhost")
print(server1) # Server(host='localhost', port=8080, protocol='http')
server2 = Server("api.example.com", 443, "https")
print(server2) # Server(host='api.example.com', port=443, protocol='https')Para valores por defecto mutables como listas o diccionarios, usa default_factory para evitar referencias compartidas:
from dataclasses import dataclass, field
# WRONG - all instances share the same list
@dataclass
class WrongConfig:
tags: list = [] # Raises error in Python 3.10+
# CORRECT - each instance gets a new list
@dataclass
class CorrectConfig:
tags: list = field(default_factory=list)
metadata: dict = field(default_factory=dict)
config1 = CorrectConfig()
config2 = CorrectConfig()
config1.tags.append("production")
print(config1.tags) # ['production']
print(config2.tags) # [] - separate listLa función field(): configuración avanzada de campos
La función field() ofrece control granular sobre campos individuales:
from dataclasses import dataclass, field
from typing import List
@dataclass
class Employee:
name: str
employee_id: int
salary: float = field(repr=False) # Oculta salary en repr
skills: List[str] = field(default_factory=list)
_internal_id: str = field(init=False, repr=False) # No está en __init__
performance_score: float = field(default=0.0, compare=False) # Se excluye de la comparación
def __post_init__(self):
self._internal_id = f"EMP_{self.employee_id:06d}"
emp = Employee("Alice", 12345, 85000.0, ["Python", "SQL"])
print(emp) # Employee(name='Alice', employee_id=12345, skills=['Python', 'SQL'], performance_score=0.0)
print(emp._internal_id) # EMP_012345Parámetros clave de field():
| Parameter | Type | Description |
|---|---|---|
default | Any | Valor por defecto para el campo |
default_factory | Callable | Función sin argumentos que devuelve el valor por defecto |
init | bool | Incluye el campo en __init__ (por defecto: True) |
repr | bool | Incluye el campo en __repr__ (por defecto: True) |
compare | bool | Incluye el campo en métodos de comparación (por defecto: True) |
hash | bool | Incluye el campo en __hash__ (por defecto: None) |
metadata | dict | Metadatos arbitrarios (no los usa el módulo dataclasses) |
kw_only | bool | Hace el campo solo-de-palabra-clave (Python 3.10+) |
El parámetro metadata almacena información arbitraria accesible mediante fields():
from dataclasses import dataclass, field, fields
@dataclass
class APIRequest:
endpoint: str = field(metadata={"description": "API endpoint path"})
method: str = field(default="GET", metadata={"choices": ["GET", "POST", "PUT", "DELETE"]})
for f in fields(APIRequest):
print(f"{f.name}: {f.metadata}")
# endpoint: {'description': 'API endpoint path'}
# method: {'choices': ['GET', 'POST', 'PUT', 'DELETE']}Anotaciones de tipo con dataclasses
Las dataclasses dependen de anotaciones de tipo, pero no las aplican en tiempo de ejecución. Usa el módulo typing para tipos complejos:
from dataclasses import dataclass
from typing import List, Dict, Optional, Union, Tuple
from datetime import datetime
@dataclass
class DataAnalysisJob:
job_id: str
dataset_path: str
columns: List[str]
filters: Dict[str, Union[str, int, float]]
output_format: str = "csv"
created_at: datetime = field(default_factory=datetime.now)
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
results: Optional[Dict[str, Tuple[float, float]]] = None
job = DataAnalysisJob(
job_id="job_001",
dataset_path="/data/sales.csv",
columns=["date", "revenue", "region"],
filters={"year": 2026, "region": "US"}
)Para verificación de tipos en runtime, intégralo con librerías como pydantic o usa validación en __post_init__.
frozen=True: crear dataclasses inmutables
Establece frozen=True para hacer las instancias inmutables tras su creación, similar a los named tuples:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
def distance_from_origin(self):
return (self.x**2 + self.y**2) ** 0.5
point = Point(3.0, 4.0)
print(point.distance_from_origin()) # 5.0
# Attempting to modify raises FrozenInstanceError
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Error: cannot assign to field 'x'Las dataclasses congeladas (frozen) son hashable por defecto si todos los campos son hashable, lo que permite usarlas en sets y como claves de diccionario:
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London"
}
print(locations[Coordinate(40.7128, -74.0060)]) # New YorkMétodo post_init: validación y campos calculados
El método __post_init__ se ejecuta después de __init__, permitiendo validación e inicialización de campos calculados:
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class BankAccount:
account_number: str
balance: float
created_at: datetime = field(default_factory=datetime.now)
account_type: str = field(init=False)
def __post_init__(self):
if self.balance < 0:
raise ValueError("Initial balance cannot be negative")
# Compute account_type based on balance
if self.balance >= 100000:
self.account_type = "Premium"
elif self.balance >= 10000:
self.account_type = "Gold"
else:
self.account_type = "Standard"
account = BankAccount("ACC123456", 50000.0)
print(account.account_type) # GoldPara campos con init=False que dependen de otros campos, usa __post_init__:
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
perimeter: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.height
self.perimeter = 2 * (self.width + self.height)
rect = Rectangle(5.0, 3.0)
print(f"Area: {rect.area}, Perimeter: {rect.perimeter}") # Area: 15.0, Perimeter: 16.0Herencia con dataclasses
Las dataclasses soportan herencia con fusión automática de campos:
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
@dataclass
class Dog(Animal):
breed: str
is_good_boy: bool = True
dog = Dog("Buddy", 5, "Golden Retriever")
print(dog) # Dog(name='Buddy', age=5, breed='Golden Retriever', is_good_boy=True)Las subclases heredan los campos del padre y pueden añadir nuevos. Los campos sin valores por defecto no pueden ir después de campos con valores por defecto a través de la herencia:
from dataclasses import dataclass
@dataclass
class BaseConfig:
environment: str = "production"
# ERROR: Non-default field 'api_key' cannot follow default field 'environment'
# @dataclass
# class APIConfig(BaseConfig):
# api_key: str
# CORRECT: Use default or rearrange fields
@dataclass
class APIConfig(BaseConfig):
api_key: str = "" # Provide default
timeout: int = 30Python 3.10+ introdujo kw_only para resolver esto:
from dataclasses import dataclass
@dataclass
class BaseConfig:
environment: str = "production"
@dataclass(kw_only=True)
class APIConfig(BaseConfig):
api_key: str # Must be passed as keyword argument
timeout: int = 30
config = APIConfig(api_key="secret_key_123") # OK
# config = APIConfig("secret_key_123") # TypeErrorslots=True: eficiencia de memoria (Python 3.10+)
Python 3.10 añadió slots=True para definir __slots__, reduciendo la sobrecarga de memoria:
from dataclasses import dataclass
import sys
@dataclass
class RegularUser:
username: str
email: str
age: int
@dataclass(slots=True)
class SlottedUser:
username: str
email: str
age: int
regular = RegularUser("john", "john@example.com", 30)
slotted = SlottedUser("jane", "jane@example.com", 28)
print(f"Regular: {sys.getsizeof(regular.__dict__)} bytes") # ~104 bytes
print(f"Slotted: {sys.getsizeof(slotted)} bytes") # ~64 bytesLas dataclasses con slots ofrecen un ahorro de memoria del 30–40% y acceso a atributos más rápido, pero sacrifican la posibilidad de añadir atributos dinámicamente:
regular.new_attribute = "allowed" # OK
# slotted.new_attribute = "error" # AttributeErrorkw_only=True: campos solo de palabra clave (Python 3.10+)
Fuerza que todos los campos sean solo-de-palabra-clave para una instanciación más clara:
from dataclasses import dataclass
@dataclass(kw_only=True)
class DatabaseConnection:
host: str
port: int
username: str
password: str
database: str = "default"
# Must use keyword arguments
conn = DatabaseConnection(
host="localhost",
port=5432,
username="admin",
password="secret"
)
# Positional arguments raise TypeError
# conn = DatabaseConnection("localhost", 5432, "admin", "secret")Combina kw_only con control por campo:
from dataclasses import dataclass, field
@dataclass
class MixedArgs:
required_positional: str
optional_positional: int = 0
required_keyword: str = field(kw_only=True)
optional_keyword: bool = field(default=False, kw_only=True)
obj = MixedArgs("value", 10, required_keyword="kw_value")Comparación: dataclass vs alternativas
| Feature | dataclass | namedtuple | TypedDict | Pydantic | attrs |
|---|---|---|---|---|---|
| Mutability | Mutable (default) | Immutable | N/A (dict subclass) | Mutable | Configurable |
| Type validation | Annotations only | No | Annotations only | Runtime validation | Runtime validation |
| Default values | Yes | Yes | No | Yes | Yes |
| Methods | Full class support | Limited | No | Full class support | Full class support |
| Inheritance | Yes | No | Limited | Yes | Yes |
| Memory overhead | Moderate | Low | Low | Higher | Moderate |
| Slots support | Yes (3.10+) | No | No | Yes | Yes |
| Performance | Fast | Fastest | Fast | Slower (validation) | Fast |
| Built-in | Yes (3.7+) | Yes | Yes (3.8+) | No | No |
Elige dataclasses para:
- Proyectos estándar de Python sin dependencias
- Contenedores de datos simples con type hints
- Cuando se necesita flexibilidad entre frozen/mutable
- Jerarquías con herencia
Elige Pydantic para:
- Validación de request/response de APIs
- Gestión de configuración con validación estricta
- Generación de JSON schema
Elige namedtuple para:
- Contenedores inmutables ligeros
- Máxima eficiencia de memoria
- Compatibilidad con Python < 3.7
Convertir a/desde diccionarios
Las dataclasses proporcionan asdict() y astuple() para serialización:
from dataclasses import dataclass, asdict, astuple
@dataclass
class Config:
host: str
port: int
ssl_enabled: bool = True
config = Config("api.example.com", 443)
# Convert to dictionary
config_dict = asdict(config)
print(config_dict) # {'host': 'api.example.com', 'port': 443, 'ssl_enabled': True}
# Convert to tuple
config_tuple = astuple(config)
print(config_tuple) # ('api.example.com', 443, True)Para dataclasses anidadas:
from dataclasses import dataclass, asdict
@dataclass
class Address:
street: str
city: str
zipcode: str
@dataclass
class Person:
name: str
address: Address
person = Person("Alice", Address("123 Main St", "Springfield", "12345"))
person_dict = asdict(person)
print(person_dict)
# {'name': 'Alice', 'address': {'street': '123 Main St', 'city': 'Springfield', 'zipcode': '12345'}}Dataclasses con serialización JSON
Las dataclasses no soportan serialización JSON de forma nativa, pero la integración es sencilla:
import json
from dataclasses import dataclass, asdict
from datetime import datetime
@dataclass
class Event:
name: str
timestamp: datetime
attendees: int
def to_json(self):
data = asdict(self)
# Custom serialization for datetime
data['timestamp'] = self.timestamp.isoformat()
return json.dumps(data)
@classmethod
def from_json(cls, json_str):
data = json.loads(json_str)
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
return cls(**data)
event = Event("Python Conference", datetime.now(), 500)
json_str = event.to_json()
print(json_str)
restored = Event.from_json(json_str)
print(restored)Para escenarios complejos, usa la librería dataclasses-json o Pydantic.
Patrones del mundo real
Objetos de configuración
from dataclasses import dataclass, field
from typing import List
@dataclass
class AppConfig:
app_name: str
version: str
debug: bool = False
allowed_hosts: List[str] = field(default_factory=lambda: ["localhost"])
database_url: str = "sqlite:///app.db"
cache_timeout: int = 300
def __post_init__(self):
if self.debug:
print(f"Running {self.app_name} v{self.version} in DEBUG mode")
config = AppConfig("DataAnalyzer", "2.1.0", debug=True)Modelos de respuesta de API
from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime
@dataclass
class APIResponse:
status: str
data: Optional[List[dict]] = None
error_message: Optional[str] = None
timestamp: datetime = field(default_factory=datetime.now)
@property
def is_success(self):
return self.status == "success"
response = APIResponse("success", data=[{"id": 1, "name": "Dataset A"}])
print(response.is_success) # TrueRegistros de base de datos con integración de PyGWalker
from dataclasses import dataclass, asdict
from typing import List
import pandas as pd
@dataclass
class SalesRecord:
date: str
product: str
revenue: float
region: str
quantity: int
# Create sample data
records = [
SalesRecord("2026-01-01", "Laptop", 1299.99, "US", 5),
SalesRecord("2026-01-02", "Mouse", 29.99, "EU", 50),
SalesRecord("2026-01-03", "Keyboard", 89.99, "US", 20),
]
# Convert to DataFrame for visualization with PyGWalker
df = pd.DataFrame([asdict(r) for r in records])
# Use PyGWalker for interactive data exploration
# import pygwalker as pyg
# walker = pyg.walk(df)
# This creates a Tableau-like interface to visualize your dataclass-based dataLas dataclasses destacan al estructurar datos antes de la visualización. PyGWalker convierte DataFrames en interfaces visuales interactivas, haciendo que los flujos de trabajo de análisis de datos basados en dataclasses sean más fluidos.
Benchmarks de rendimiento vs clases normales
import timeit
from dataclasses import dataclass
# Regular class
class RegularClass:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def __repr__(self):
return f"RegularClass(x={self.x}, y={self.y}, z={self.z})"
def __eq__(self, other):
return (self.x, self.y, self.z) == (other.x, other.y, other.z)
@dataclass
class DataClass:
x: int
y: int
z: int
# Benchmark instantiation
regular_time = timeit.timeit(lambda: RegularClass(1, 2, 3), number=1000000)
dataclass_time = timeit.timeit(lambda: DataClass(1, 2, 3), number=1000000)
print(f"Regular class: {regular_time:.4f}s")
print(f"Dataclass: {dataclass_time:.4f}s")
# Dataclasses are typically 5-10% slower due to decorator overhead
# but provide significantly cleaner codeCon slots=True (Python 3.10+), las dataclasses igualan o superan el rendimiento de clases normales, reduciendo el uso de memoria en un 30–40%.
Patrones avanzados: ordenación personalizada de campos
from dataclasses import dataclass, field
def sort_by_priority(items):
return sorted(items, key=lambda x: x.priority, reverse=True)
@dataclass(order=True)
class Task:
priority: int
name: str = field(compare=False)
description: str = field(compare=False)
tasks = [
Task(3, "Review PR", "Code review for feature X"),
Task(1, "Write docs", "Documentation update"),
Task(5, "Fix bug", "Critical production issue"),
]
sorted_tasks = sorted(tasks)
for task in sorted_tasks:
print(f"Priority {task.priority}: {task.name}")
# Priority 1: Write docs
# Priority 3: Review PR
# Priority 5: Fix bugBuenas prácticas y trampas comunes (gotchas)
- Usa siempre
default_factorypara valores mutables por defecto: nunca asignes[]o{}directamente - Los type hints son obligatorios: las dataclasses dependen de anotaciones, no de valores
- El orden de campos importa: campos sin default antes que campos con default
frozen=Truepara datos inmutables: útil para objetos hashable y seguridad en concurrencia- Usa
__post_init__con moderación: demasiada lógica reduce la simplicidad de dataclass - Considera
slots=Truepara datasets grandes: gran ahorro de memoria en Python 3.10+ - Valida en
__post_init__: las dataclasses no hacen cumplir tipos en runtime
Preguntas frecuentes (FAQ)
Conclusión
Las dataclasses de Python eliminan el boilerplate mientras preservan todo el poder de las clases. El decorador @dataclass genera automáticamente métodos de inicialización, representación y comparación, reduciendo el tiempo de desarrollo y la carga de mantenimiento. Desde objetos de configuración hasta modelos de API y registros de base de datos, las dataclasses ofrecen un enfoque limpio, con anotaciones de tipo, para clases que contienen datos.
Las ventajas clave incluyen generación automática de métodos, comportamiento de campos personalizable mediante field(), inmutabilidad con frozen=True, validación vía __post_init__ y eficiencia de memoria con slots=True. Aunque alternativas como namedtuples y Pydantic cubren casos de uso específicos, las dataclasses logran un equilibrio óptimo entre simplicidad y funcionalidad para la mayoría de proyectos Python.
Para flujos de trabajo de análisis de datos, combinar dataclasses con herramientas como PyGWalker crea pipelines potentes donde modelos de datos estructurados alimentan directamente visualizaciones interactivas, agilizando todo: desde la ingesta de datos hasta la generación de insights.