msgspec Structs to JSON Schema with constraints and tagged unions: working versions (tested 2026-10-18)
Generate JSON Schema from msgspec Structs using Annotated Meta constraints, tagged unions and optional fields, then decode payloads with the same validation rules.
passed6 passed · 0 failed2026-10-18 03:57 UTC- Status
- passed
- Last passed
- 2026-10-18 03:57 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 39095370682
The code
Exactly the files that passed. Your agent can pull them with get_recipe("msgspec-json-schema-generation") (install), or see them on GitHub.
msgspec==0.22.0 pytest==9.1.1
from typing import Annotated, Union
import msgspec
class User(msgspec.Struct, tag="user"):
name: Annotated[str, msgspec.Meta(min_length=1, max_length=50, pattern="^[a-z]+$")]
age: Annotated[int, msgspec.Meta(ge=0, le=120)]
nickname: str | None = None
class Team(msgspec.Struct, tag="team"):
team_name: Annotated[str, msgspec.Meta(min_length=1)]
members: list[str] = []
Message = Union[User, Team]
def user_schema() -> dict:
return msgspec.json.schema(User)
def message_schema() -> dict:
return msgspec.json.schema(Message)
def deref(schema: dict, node: dict) -> dict:
ref = node.get("$ref")
return schema["$defs"][ref.rsplit("/", 1)[-1]] if ref else node
def decode_message(payload: bytes) -> User | Team:
return msgspec.json.decode(payload, type=Message)
import msgspec
import pytest
from models import User, decode_message, deref, message_schema, user_schema
def test_meta_constraints_become_json_schema_keywords():
schema = user_schema()
user = deref(schema, schema)
assert user["properties"]["age"] == {"type": "integer", "minimum": 0, "maximum": 120}
assert user["properties"]["name"]["minLength"] == 1
assert user["properties"]["name"]["maxLength"] == 50
assert user["properties"]["name"]["pattern"] == "^[a-z]+$"
def test_tagged_union_schema_emits_discriminator():
schema = message_schema()
assert set(schema["$defs"]) == {"User", "Team"}
assert schema["discriminator"] == {
"propertyName": "type",
"mapping": {"user": "#/$defs/User", "team": "#/$defs/Team"},
}
assert all(ref["$ref"].startswith("#/$defs/") for ref in schema["anyOf"])
def test_tagged_union_dispatches_on_tag():
user = decode_message(b'{"type":"user","name":"bob","age":30}')
assert isinstance(user, User) and user.age == 30
team = decode_message(b'{"type":"team","team_name":"core"}')
assert team.members == []
with pytest.raises(msgspec.ValidationError):
decode_message(b'{"type":"nope","name":"bob"}')
def test_missing_tag_ok_lone_but_not_in_union():
assert msgspec.json.decode(b'{"name":"bob","age":1}', type=User).name == "bob"
with pytest.raises(msgspec.ValidationError):
decode_message(b'{"name":"bob","age":1}')
def test_optional_field_is_not_required():
schema = user_schema()
user = deref(schema, schema)
assert user["required"] == ["type", "name", "age"]
assert user["properties"]["nickname"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
assert user["properties"]["nickname"]["default"] is None
assert decode_message(b'{"type":"user","name":"bob","age":1}').nickname is None
def test_decode_enforces_meta_constraints():
for payload in (
b'{"type":"user","name":"Bob","age":1}',
b'{"type":"user","name":"","age":1}',
b'{"type":"user","name":"bob","age":121}',
b'{"type":"user","name":"bob","age":"1"}',
):
with pytest.raises(msgspec.ValidationError):
decode_message(payload)
# fields absent from the schema are ignored unless forbid_unknown_fields is set
assert decode_message(b'{"type":"user","name":"bob","age":1,"x":2}').name == "bob"
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
msgspec Structs with tagged unions, constraints and fast codecs
Define msgspec Structs with tagged unions and Meta constraints, encode and decode JSON and MessagePack, inspect ValidationError paths, and convert plain dicts, all tested offline.
msgspec tagged union Structs over MessagePack and JSON
Model a tagged union of msgspec Structs, round-trip it through MessagePack and JSON, and verify tag selection and strict-field validation under pytest.
orjson and msgspec encode/decode parity helpers
Run orjson and msgspec side by side with canonical sort-key and non-str-key options, a shared Struct hook and typed decoding, and pin down where their bytes diverge.