Blog

Python Dataclass to Dict: asdict(), Nested Dataclasses and JSON

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_factory applies to every level of the tree, so key renaming is one function.

  • asdict produces a dict, not JSON. date and Decimal still need default=, and Decimal should become a string, not a 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.