You have a dataclass and you need a dict — to serialize it, to log it, to hand it to a library that only speaks dicts. There are three ways to do it and they behave differently in ways that matter.
asdict() is the answer most of the time
from dataclasses import dataclass, asdict
@dataclass
class Point:
x: int
y: int
p = Point(3, 4)
print(asdict(p))
It prints:
{'x': 3, 'y': 4}
That is the whole API for the simple case. It gets interesting one level down.
It recurses, and that is the point
asdict does not stop at the top. It walks nested dataclasses, and the lists, tuples and dicts that contain them.
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))
It prints:
{'name': 'ada', 'address': {'city': 'london', 'country': 'uk'}, 'tags': ['engineer', 'mathematician']}
The nested Address became a nested dict. Nothing in the output is still a dataclass, which is exactly what a serializer needs.
asdict() copies. vars() does not
This is the difference that causes bugs, and it is easy to miss because both produce a dict that looks right.
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'])
It prints:
original : ['ada', 'grace', 'MUTATED VIA vars']
asdict : ['ada', 'grace', 'MUTATED VIA asdict']
vars : ['ada', 'grace', 'MUTATED VIA vars']
Read the first line. Appending to the asdict result left the object alone. Appending to the vars result changed the object, because vars(t) hands back the instance’s actual __dict__ — not a copy of it, the thing itself.
asdict deep-copies every value on the way out. vars gives you a live reference. If you are about to hand the dict to something that mutates, that distinction is your bug.
fields() when you want control
asdict is all or nothing. When you need to skip a field, rename one, or stop before recursing, iterate the fields yourself.
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)])
It prints:
{'name': 'ada', 'email': 'ada@example.com'}
['name', 'email', 'password']
fields() returns the field definitions, so you can filter on anything they carry — including your own metadata.
Marking a field as never-exported
field(metadata=...) is the tidy way to say it once, at the definition, instead of maintaining a list of names somewhere else.
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))
It prints:
{'user': 'ada'}
dict_factory changes how every level is built
asdict takes a factory that receives a list of (key, value) pairs. It is called for the top-level object and for every nested dataclass, so one function covers the whole tree.
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))
It prints:
{'inner': {'firstName': 'ada'}, 'isActive': True}
Both levels were renamed by one function.
JSON, and the types that stop it
json.dumps(asdict(obj)) works right up until a field holds something JSON has no opinion about.
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))
It prints:
TypeError: Object of type date is not JSON serializable
{"id": 17, "issued": "2026-09-09", "total": "249.50"}
asdict did its job — it produced a dict. It does not convert leaf values, and it was never meant to. Type conversion belongs in default=, or in a dict_factory if you want it to happen at dict-building time instead.
Note Decimal became a string, not a float. float(Decimal('249.50')) would introduce exactly the rounding error the Decimal was there to prevent.
Three mistakes worth naming
Calling asdict in a hot loop. It deep-copies the entire tree every time. If you only need the field names, fields() is far cheaper.
Expecting it on a non-dataclass. It raises rather than guessing.
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()))
It prints:
TypeError: asdict() should be called on dataclass instances
is_dataclass: False
Reaching for vars() on a slotted class. A dataclass with slots=True has no __dict__ at all, so the shortcut is not merely risky, it fails outright.
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)
It prints:
asdict works: {'x': 1, 'y': 2}
TypeError: vars() argument must have __dict__ attribute
asdict reads the field definitions, not __dict__, so it keeps working. That is one more reason to make it the default choice.
What to remember
-
asdict(obj)recurses through nested dataclasses, lists, tuples and dicts, and deep-copies every value on the way. -
vars(obj)is shallow and returns the live__dict__. Mutating what you get back mutates the object, and it fails on a slotted dataclass. -
fields(obj)when you need to skip, rename or stop early.field(metadata=...)records that decision next to the field itself. -
dict_factoryapplies to every level of the tree, so key renaming is one function. -
asdictproduces a dict, not JSON.dateandDecimalstill needdefault=, andDecimalshould become a string, not a float.