Skip to content

Default not omitted with custom default factory #1032

Closed

Description

@Leo-Marbehan

Description

In my project, I have structs that contains list fields. I would like to omit these fields when the list is empty to reduce the size of the saved files.

When using the list constructor as the default factory or a default empty list, it works fine. However, the former conflicts with the type linter, and I want to avoid the latter (shared across all instances).

When I use a custom factory function, the field always appear in the output.

It may be the correct behavior, but I did not find any mention of this in the docs.

Reproduction

I ran this using Python 3.14 and msgspec 0.21.1.

from __future__ import annotations

import msgspec


class Fruit(msgspec.Struct):
    name: str = msgspec.field(name="name")


def fruits_factory() -> list[Fruit]:
    return []


class Basket(msgspec.Struct, omit_defaults=True):
    size: int = msgspec.field(name="size")
    material: str = msgspec.field(name="material", default="wicker")
    fruits: list[Fruit] = msgspec.field(name="fruits", default_factory=fruits_factory)


class BasketWithListConstructor(msgspec.Struct, omit_defaults=True):
    size: int = msgspec.field(name="size")
    material: str = msgspec.field(name="material", default="wicker")
    fruits: list[Fruit] = msgspec.field(name="fruits", default_factory=list)  # Type error because list is untyped


class BasketWithDefaultValue(msgspec.Struct, omit_defaults=True):
    size: int = msgspec.field(name="size")
    material: str = msgspec.field(name="material", default="wicker")
    fruits: list[Fruit] = msgspec.field(name="fruits", default=[])  # Shared mutable default value, should be avoided


if __name__ == "__main__":
    basket = Basket(size=5)

    print(msgspec.json.encode(basket))
    # Expected output: b'{"size":5}'
    # Actual output: b'{"size":5,"fruits":[]}'

    basket_with_list_constructor = BasketWithListConstructor(size=5)
    print(msgspec.json.encode(basket_with_list_constructor))
    # Output: b'{"size":5}'

    basket_with_default_value = BasketWithDefaultValue(size=5)
    print(msgspec.json.encode(basket_with_default_value))
    # Output: b'{"size":5}'

    print(f"Using msgspec {msgspec.__version__}")

Activity

  1. Siyet commented on Jun 16, 2026

    @Siyet
    Contributor

    This is intended behavior, though it wasn't documented, which I'll fix.

    omit_defaults is optimized for performance: the "value equals default" check is O(1) by type and length and never calls user code. So for default_factory, omitting an empty collection only works when the factory is a builtin constructor (list/dict/set, plus tuple/frozenset). A custom factory is opaque to msgspec: knowing what it returns would mean calling it on every encode (too expensive) or once at class-creation time (side effects at import, and unsound for a dynamic factory). The same performance constraint is why #645 is unaddressed.

    On your two reasons for preferring a custom factory:

    • default=[] is safe in msgspec. Unlike dataclasses, msgspec does not share the mutable default between instances; each instance gets a fresh list. So there's no shared-mutable footgun here.

    • The type-checker error on default_factory=list is resolved by the field annotation. The element type comes from the annotation, not the factory:

      fruits: list[Fruit] = msgspec.field(default_factory=list)

      This both omits when empty and type checks.

    I'd recommend that spelling. Documenting the default_factory limitation in #1076.

  2. Leo-Marbehan commented on Jun 16, 2026

    @Leo-Marbehan
    Author

    Thank you!

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