Blog

De dataclass a dict en Python: asdict(), anidamiento y JSON

Tienes una dataclass y necesitas un diccionario: para serializarla, para registrarla en un log, o para pasársela a una biblioteca que solo habla diccionarios. Hay tres formas de hacerlo y se comportan distinto de maneras que importan.

asdict() es la respuesta casi siempre

from dataclasses import dataclass, asdict

@dataclass
class Point:
    x: int
    y: int

p = Point(3, 4)
print(asdict(p))

Imprime:

{'x': 3, 'y': 4}

Esa es toda la API para el caso simple. Se pone interesante un nivel más abajo.

Baja recursivamente, y ese es el punto

asdict no se detiene en el nivel superior. Recorre las dataclasses anidadas, y las listas, tuplas y diccionarios que las contienen.

from dataclasses import dataclass, asdict, field

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Person:
    name: str
    address: Address
    tags: list = field(default_factory=list)

p = Person('ada', Address('london', 'uk'), ['engineer', 'mathematician'])
print(asdict(p))

Imprime:

{'name': 'ada', 'address': {'city': 'london', 'country': 'uk'}, 'tags': ['engineer', 'mathematician']}

El Address anidado se volvió un diccionario anidado. Nada en la salida sigue siendo una dataclass, que es exactamente lo que un serializador necesita.

asdict() copia. vars() no

Esta es la diferencia que causa errores, y es fácil pasarla por alto porque las dos producen un diccionario que se ve bien.

from dataclasses import dataclass, asdict, field

@dataclass
class Team:
    name: str
    members: list = field(default_factory=list)

t = Team('core', ['ada', 'grace'])

copied  = asdict(t)
shallow = vars(t)

copied['members'].append('MUTATED VIA asdict')
shallow['members'].append('MUTATED VIA vars')

print('original :', t.members)
print('asdict   :', copied['members'])
print('vars     :', shallow['members'])

Imprime:

original : ['ada', 'grace', 'MUTATED VIA vars']
asdict   : ['ada', 'grace', 'MUTATED VIA asdict']
vars     : ['ada', 'grace', 'MUTATED VIA vars']

Lee la primera línea. Agregar al resultado de asdict dejó el objeto intacto. Agregar al resultado de vars cambió el objeto, porque vars(t) te devuelve el __dict__ real de la instancia: no una copia, la cosa misma.

asdict hace una copia profunda de cada valor al salir. vars te da una referencia viva. Si estás por entregarle el diccionario a algo que muta, esa distinción es tu error.

fields() cuando quieres control

asdict es todo o nada. Cuando necesitas saltarte un campo, renombrar otro, o detenerte antes de bajar recursivamente, recorre los campos tú mismo.

from dataclasses import dataclass, fields, field

@dataclass
class User:
    name: str
    email: str
    password: str = field(repr=False)

u = User('ada', 'ada@example.com', 'hunter2')

public = {f.name: getattr(u, f.name) for f in fields(u) if f.name != 'password'}
print(public)
print([f.name for f in fields(u)])

Imprime:

{'name': 'ada', 'email': 'ada@example.com'}
['name', 'email', 'password']

fields() devuelve las definiciones de los campos, así que puedes filtrar por cualquier cosa que lleven, incluidos tus propios metadatos.

Marcar un campo como «nunca exportar»

field(metadata=...) es la forma ordenada de decirlo una sola vez, en la definición, en lugar de mantener una lista de nombres en otro lado.

from dataclasses import dataclass, fields, field

@dataclass
class Account:
    user: str
    token: str = field(metadata={'private': True})

def public_dict(obj):
    return {f.name: getattr(obj, f.name)
            for f in fields(obj) if not f.metadata.get('private')}

a = Account('ada', 'secret-token-value')
print(public_dict(a))

Imprime:

{'user': 'ada'}

dict_factory cambia cómo se construye cada nivel

asdict acepta una fábrica que recibe una lista de pares (clave, valor). Se llama para el objeto de nivel superior y para cada dataclass anidada, así que una sola función cubre todo el árbol.

from dataclasses import dataclass, asdict

@dataclass
class Inner:
    first_name: str

@dataclass
class Outer:
    inner: Inner
    is_active: bool

def camel(pairs):
    def to_camel(s):
        head, *rest = s.split('_')
        return head + ''.join(w.capitalize() for w in rest)
    return {to_camel(k): v for k, v in pairs}

print(asdict(Outer(Inner('ada'), True), dict_factory=camel))

Imprime:

{'inner': {'firstName': 'ada'}, 'isActive': True}

Los dos niveles se renombraron con una sola función.

JSON, y los tipos que lo detienen

json.dumps(asdict(obj)) funciona hasta el momento exacto en que un campo guarda algo sobre lo que JSON no tiene opinión.

import json
from dataclasses import dataclass, asdict
from datetime import date
from decimal import Decimal

@dataclass
class Invoice:
    id: int
    issued: date
    total: Decimal

inv = Invoice(17, date(2026, 9, 9), Decimal('249.50'))

try:
    print(json.dumps(asdict(inv)))
except TypeError as err:
    print('TypeError:', err)

def encode(value):
    if isinstance(value, Decimal):
        return str(value)
    if isinstance(value, date):
        return value.isoformat()
    raise TypeError(f'cannot serialize {type(value).__name__}')

print(json.dumps(asdict(inv), default=encode))

Imprime:

TypeError: Object of type date is not JSON serializable
{"id": 17, "issued": "2026-09-09", "total": "249.50"}

asdict hizo su trabajo: produjo un diccionario. No convierte los valores de las hojas, y nunca tuvo esa intención. La conversión de tipos va en default=, o en un dict_factory si prefieres que ocurra al construir el diccionario.

Fíjate que Decimal se volvió una cadena, no un float. float(Decimal('249.50')) introduciría justo el error de redondeo que el Decimal estaba ahí para evitar.

Tres errores que vale la pena nombrar

Llamar a asdict dentro de un bucle caliente. Copia en profundidad todo el árbol cada vez. Si solo necesitas los nombres de los campos, fields() es mucho más barato.

Esperarlo sobre algo que no es una dataclass. Lanza un error en lugar de adivinar.

from dataclasses import asdict, is_dataclass

class Plain:
    def __init__(self):
        self.x = 1

try:
    asdict(Plain())
except TypeError as err:
    print('TypeError:', err)

print('is_dataclass:', is_dataclass(Plain()))

Imprime:

TypeError: asdict() should be called on dataclass instances
is_dataclass: False

Recurrir a vars() en una clase con slots. Una dataclass con slots=True no tiene __dict__ en absoluto, así que el atajo no es solo arriesgado: falla directamente.

from dataclasses import dataclass, asdict

@dataclass(slots=True)
class Fast:
    x: int
    y: int

f = Fast(1, 2)
print('asdict works:', asdict(f))

try:
    print(vars(f))
except TypeError as err:
    print('TypeError:', err)

Imprime:

asdict works: {'x': 1, 'y': 2}
TypeError: vars() argument must have __dict__ attribute

asdict lee las definiciones de los campos, no __dict__, así que sigue funcionando. Esa es una razón más para que sea la opción por defecto.

Qué recordar

  • asdict(obj) baja por dataclasses anidadas, listas, tuplas y diccionarios, y copia en profundidad cada valor por el camino.

  • vars(obj) es superficial y devuelve el __dict__ vivo. Mutar lo que recibes muta el objeto, y falla en una dataclass con slots.

  • fields(obj) cuando necesites saltar, renombrar o detenerte antes. field(metadata=...) deja esa decisión anotada junto al campo.

  • dict_factory aplica a todos los niveles del árbol, así que renombrar claves es una sola función.

  • asdict produce un diccionario, no JSON. date y Decimal siguen necesitando default=, y Decimal debería volverse una cadena, no un float.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.