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.
Summary
sqlspec.utils.serializers._schema.schema_dump(and the underlying_dump_msgspec_fields/_dump_msgspec_excluding_unsetpipeline closures) iteratevalue.__struct_fields__, which returns Python attribute names regardless of the Struct'srename=meta. Structs declared withrename="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_configwhich detects the rename pattern viamsgspec.structs.fields(cls)[i].encode_namecomparison — but the dump path never consults that information.Reproducer
Root cause
sqlspec/utils/serializers/_schema.py:127-136:__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-tripget_msgspec_rename_configalready implements for other use cases).For Structs without a rename config,
field.encode_name == field.name, so behaviour is unchanged (snake_case in, snake_case out). Forrename="camel",field.encode_nameis"camelCase". Same forkebab,pascal, and custom rename callables.Verified on msgspec 0.18.x:
structs.fields(cls)returnsFieldInfoobjects each with.name(Python attribute) and.encode_name(wire name).Test matrix for the fix
rename="camel"→ camelCase keysrename="kebab"→ kebab-case keysrename="pascal"→ PascalCase keysrename=callable→ callable-transformed keysrenamemeta → snake_case keys (regression pin)exclude_unset=True+ rename → renamed keys with UNSET still strippedImpact
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.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 vendorsqlspec's cached-pipeline serializer architecture intolitestar-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.