Skip to content

Structs with nested array_like Structs asdict #830

Closed as not planned
Closed as not planned

Description

@mledezma-r7

Question

Hey folks, I went over #594 because of the same assumptions.
I was expecting asdict to work on nested Structs, at least as an option.

This is where I'm struggling:

import msgspec

class Dog(msgspec.Struct, array_like=True):
    name: str
    age: int

class Owner(msgspec.Struct):
    name: str
    dogs: list[Dog]

owner = Owner('A dog owner', [Dog('Doggie', 2), Dog('Max', 1)])

msgspec.structs.asdict(owner)
>> {'name': 'A dog owner', 'dogs': [Dog(name='Doggie', age=2), Dog(name='Max', age=1)]}

msgspec.to_builtins(owner)
>> {'name': 'A dog owner', 'dogs': [['Doggie', 2], ['Max', 1]]}

I need to come up with the following:

>>> {'name': 'A dog owner',  'dogs': [{'name': 'Doggie', 'age': 2}, {'name': 'Max', 'age': 1}]}

I tried working around the issue with the following, but most of the performance gains vanish.

def asdict(obj):
    if isinstance(obj, msgspec.Struct):
        return asdict(msgspec.structs.asdict(obj))
    if isinstance(obj, dict):
        return {key: asdict(value) for key, value in obj.items()}
    if isinstance(obj, list):
        return [asdict(item) for item in obj]
    return obj

Is there any plan to support the nested structs this way in asdict in the short term? or can you guys think of a good workaround? Thank you

Activity

  1. Siyet commented on May 26, 2026

    @Siyet
    Contributor

    Apologies for the long silence on this.

    The behavior is intentional: msgspec.structs.asdict is shallow by design (#299 and #297). The two intended paths are:

    • msgspec.structs.asdict / astuple - one-to-one conversion of a single struct instance, no recursion, no encoder settings applied. Modeled after dataclasses.asdict / dataclasses.astuple but, unlike those, doesn't descend into nested values.
    • msgspec.to_builtins - the encoding pipeline, recurses into nested structs and applies all encoder settings, including array_like.

    Your case sits in between: you want recursion (like to_builtins) but not array_like (like asdict). msgspec doesn't expose a third mode for that. With array_like=True on Dog, to_builtins(owner) will always emit lists for the dogs - that's the trade-off array_like opts you into across the entire encoder surface.

    The new docs section in #1025 spells the split out explicitly: Converting to and from Builtin Types. Hopefully that makes the boundary easier to find next time.

    For the dict-with-nested-dicts output, the cleanest workaround is a small recursive helper that walks __struct_fields__:

    def asdict_recursive(obj):
        if isinstance(obj, msgspec.Struct):
            return {f: asdict_recursive(getattr(obj, f)) for f in obj.__struct_fields__}
        if isinstance(obj, (list, tuple)):
            return [asdict_recursive(x) for x in obj]
        if isinstance(obj, dict):
            return {k: asdict_recursive(v) for k, v in obj.items()}
        return obj

    For your example:

    asdict_recursive(owner)
    # {'name': 'A dog owner', 'dogs': [{'name': 'Doggie', 'age': 2}, {'name': 'Max', 'age': 1}]}

    This is pure Python and noticeably slower than the C path (in a quick local bench it's ~7x slower than to_builtins(owner)), but for one-off conversions outside hot loops it's usually fine.

    Closing as not planned since adding an ignore_array_like (or similar) flag conflicts with the deliberate split between asdict and to_builtins. If you have a use case where the recursive helper isn't sufficient, happy to reopen the discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions