Summary
to_schema infers a msgspec struct's rename convention from the outer struct's own field names, but then applies that single verdict recursively to nested data. When every top-level field name is unchanged by the rename function (e.g. single-word names under rename="camel"), detection returns None and no key transformation runs — so nested structs whose field names do change are handed untransformed keys and fail to decode.
The outer struct is correctly configured. It is simply indistinguishable, by this heuristic, from a struct with no rename config at all.
Reproducer
import msgspec
from sqlspec.utils.schema import to_schema
from sqlspec.utils.type_guards import get_msgspec_rename_config
class Child(msgspec.Struct, rename="camel"):
item_name: str
item_count: int
class OuterSingleWord(msgspec.Struct, rename="camel"):
rows: list[Child] # camelize("rows") == "rows"
total: int # camelize("total") == "total"
class OuterMultiWord(msgspec.Struct, rename="camel"):
child_rows: list[Child]
child_total: int
rows = [{"item_name": "a", "item_count": 1}]
print(get_msgspec_rename_config(OuterSingleWord)) # None <-- misdetected
print(get_msgspec_rename_config(OuterMultiWord)) # camel
print(get_msgspec_rename_config(Child)) # camel
to_schema({"child_rows": rows, "child_total": 1}, schema_type=OuterMultiWord) # OK
to_schema({"rows": rows, "total": 1}, schema_type=OuterSingleWord) # raises
Output:
rename detected on OuterSingleWord: None
rename detected on OuterMultiWord : camel
rename detected on Child : camel
-- OuterMultiWord (top-level name changes under camelize) --
OK: OuterMultiWord(child_rows=[Child(item_name='a', item_count=1)], child_total=1)
-- OuterSingleWord (top-level names unchanged under camelize) --
FAILED: ValidationError: Object missing required field `itemName` - at `$.rows[0]`
The error is the tell: msgspec asks for itemName while the payload still carries item_name, because no transformation ran.
Cause
get_msgspec_rename_config (sqlspec/utils/type_guards.py) returns on the first field whose name differs from its encode name, and None if none differ:
for field in fields:
if field.name != field.encode_name:
rename_config = _detect_rename_pattern(field.name, field.encode_name)
_MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = rename_config
return rename_config
_MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = None
return None
_convert_msgspec (sqlspec/utils/schema.py) then gates the recursive transform on that one verdict:
converter = _MSGSPEC_RENAME_CONVERTERS.get(rename_config) if rename_config else None
if converter:
transformed_data = ... transform_dict_keys(item, converter) ...
So the root struct's field-name shape decides whether children get transformed, even though Child reports camel when asked directly.
The result cached in _MSGSPEC_RENAME_CONFIG_CACHE makes it sticky for the process.
Impact
Any response struct that wraps a list of rows in single-word fields — rows/total, items/count, data — silently fails to decode under rename="camel", while a semantically identical struct with multi-word field names works. The failure surfaces as a confusing ValidationError on a nested field, pointing away from the actual cause.
Renaming the outer fields to something multi-word is an effective workaround, which is an odd constraint to place on a public response shape.
Suggested direction
Either would fix it:
- Use the declared config rather than inferring it. The rename is known at class-definition time; the inference exists only because msgspec doesn't retain the original parameter. Deriving it per struct from
encode_name for a known-multi-word probe, or threading the configuration explicitly, avoids the ambiguity.
- Resolve rename per nested struct rather than once at the root. Since
Child already reports camel correctly when queried directly, transforming during a type-aware descent — rather than transforming the whole payload up front based on the root's verdict — would decode nested structs correctly regardless of the outer struct's field names.
Option 2 also fixes the mixed case, where an outer struct and a nested struct use different rename conventions.
Environment
- sqlspec 0.58.2
- msgspec (as pinned by sqlspec)
- Python 3.12, Linux
Related
Possibly adjacent to #418 and #434, which covered schema_dump and msgspec rename handling in the serialization direction. This one is to_schema (deserialization) and a distinct mechanism — the recursive-transform gate — so it may not be covered by those fixes.
Summary
to_schemainfers a msgspec struct'srenameconvention from the outer struct's own field names, but then applies that single verdict recursively to nested data. When every top-level field name is unchanged by the rename function (e.g. single-word names underrename="camel"), detection returnsNoneand no key transformation runs — so nested structs whose field names do change are handed untransformed keys and fail to decode.The outer struct is correctly configured. It is simply indistinguishable, by this heuristic, from a struct with no rename config at all.
Reproducer
Output:
The error is the tell: msgspec asks for
itemNamewhile the payload still carriesitem_name, because no transformation ran.Cause
get_msgspec_rename_config(sqlspec/utils/type_guards.py) returns on the first field whose name differs from its encode name, andNoneif none differ:_convert_msgspec(sqlspec/utils/schema.py) then gates the recursive transform on that one verdict:So the root struct's field-name shape decides whether children get transformed, even though
Childreportscamelwhen asked directly.The result cached in
_MSGSPEC_RENAME_CONFIG_CACHEmakes it sticky for the process.Impact
Any response struct that wraps a list of rows in single-word fields —
rows/total,items/count,data— silently fails to decode underrename="camel", while a semantically identical struct with multi-word field names works. The failure surfaces as a confusingValidationErroron a nested field, pointing away from the actual cause.Renaming the outer fields to something multi-word is an effective workaround, which is an odd constraint to place on a public response shape.
Suggested direction
Either would fix it:
encode_namefor a known-multi-word probe, or threading the configuration explicitly, avoids the ambiguity.Childalready reportscamelcorrectly when queried directly, transforming during a type-aware descent — rather than transforming the whole payload up front based on the root's verdict — would decode nested structs correctly regardless of the outer struct's field names.Option 2 also fixes the mixed case, where an outer struct and a nested struct use different rename conventions.
Environment
Related
Possibly adjacent to #418 and #434, which covered
schema_dumpand msgspec rename handling in the serialization direction. This one isto_schema(deserialization) and a distinct mechanism — the recursive-transform gate — so it may not be covered by those fixes.