Skip to content

schema_dump ignores msgspec rename meta — Struct with rename="camel" emits snake_case field names #418

@cofin

Description

@cofin

Summary

sqlspec.utils.serializers._schema.schema_dump (and the underlying _dump_msgspec_fields / _dump_msgspec_excluding_unset pipeline closures) iterate value.__struct_fields__, which returns Python attribute names regardless of the Struct's rename= meta. Structs declared with rename="camel" / "kebab" / "pascal" / callable emit the Python attribute names rather than the renamed wire names.

This is internally inconsistent with sqlspec itself: the type_guard module ships sqlspec.utils.type_guards.get_msgspec_rename_config which detects the rename pattern via msgspec.structs.fields(cls)[i].encode_name comparison — but the dump path never consults that information.

Reproducer

import msgspec
from sqlspec.utils.serializers._schema import schema_dump

class User(msgspec.Struct, rename="camel"):
    user_id: str
    display_name: str

obj = User(user_id="abc-123", display_name="Cody")
print(schema_dump(obj))
# Actual:   {'user_id': 'abc-123', 'display_name': 'Cody'}
# Expected: {'userId': 'abc-123', 'displayName': 'Cody'}

Root cause

sqlspec/utils/serializers/_schema.py:127-136:

def _dump_msgspec_fields(value: Any) -> "dict[str, Any]":
    return {field_name: value.__getattribute__(field_name) for field_name in value.__struct_fields__}


def _dump_msgspec_excluding_unset(value: Any) -> "dict[str, Any]":
    return {
        field_name: field_value
        for field_name in value.__struct_fields__
        if (field_value := value.__getattribute__(field_name)) != UNSET
    }

__struct_fields__ is a tuple of the Python attribute names. It does not apply any rename transformation.

Proposed fix

msgspec exposes the rename-applied wire name per field via msgspec.structs.fields(cls)[i].encode_name. Populated at class creation from the rename config, so no heuristic reconstruction is needed (and avoids the round-trip get_msgspec_rename_config already implements for other use cases).

from msgspec import structs

def _dump_msgspec_fields(value: Any) -> "dict[str, Any]":
    return {
        field.encode_name: value.__getattribute__(field.name)
        for field in structs.fields(type(value))
    }


def _dump_msgspec_excluding_unset(value: Any) -> "dict[str, Any]":
    return {
        field.encode_name: field_value
        for field in structs.fields(type(value))
        if (field_value := value.__getattribute__(field.name)) != UNSET
    }

For Structs without a rename config, field.encode_name == field.name, so behaviour is unchanged (snake_case in, snake_case out). For rename="camel", field.encode_name is "camelCase". Same for kebab, pascal, and custom rename callables.

Verified on msgspec 0.18.x: structs.fields(cls) returns FieldInfo objects each with .name (Python attribute) and .encode_name (wire name).

Test matrix for the fix

  • rename="camel" → camelCase keys
  • rename="kebab" → kebab-case keys
  • rename="pascal" → PascalCase keys
  • rename=callable → callable-transformed keys
  • No rename meta → snake_case keys (regression pin)
  • exclude_unset=True + rename → renamed keys with UNSET still stripped

Impact

  • Existing Structs without rename: no change.
  • Structs with rename=...: cached serializer pipelines now emit wire-correct keys. Any persisted cache state (e.g. if the cache survives process restarts — currently it's in-memory only, so this is moot) is unaffected.
  • Apps that explicitly depended on snake_case output despite declaring rename="camel" on the Struct have conflicting intent; flagging in release notes should cover it.

Context

Filed from investigation into a parallel bug in litestar-mcp (cofin/litestar-mcp#42), where the same __struct_fields__ pattern silently drops rename meta. Surfaced while evaluating whether to vendor sqlspec's cached-pipeline serializer architecture into litestar-mcp (which we're doing — along with the rename fix applied on the way in). Raising here so sqlspec consumers get the same correctness.

Happy to send a PR if helpful.

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