msgspec review
msgspec 0.21.1 combines serialization and typed validation, most often by decoding JSON or MessagePack straight into classes derived from `msgspec.Struct`. The same annotations also support YAML and TOML adapters, conversion of existing Python values, tagged unions, field constraints, custom hooks, and JSON Schema generation. Its speed comes partly from a compiled extension and reusable codec objects. The 0.21.1 patch stops rewrapping `ValidationError` and `DecodeError` raised by `dec_hook`, fixes a possible null dereference and an integer-constraint reference leak, adds the missing `ref_template` type stub, and documents migration from orjson.
msgspec 0.21.1 installed as a 1 MB compiled package in 0.2 seconds on our sandbox and imported in 0.15 seconds, making it a lean typed-codec candidate. Choose it for message boundaries where one-error validation and `Struct` fit; choose Pydantic when model hooks, ecosystem integration, and aggregated errors matter more.
We installed it
| Install | ✓ · 0.2s | 1 package on disk · 1 MB |
| Import | ✓ | import msgspec in 0.15s · compiled extensions · py.typed · requires Python >=3.10 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does msgspec install cleanly?
Yes. In a fresh container with an empty cache, pip install msgspec finished in 0.2s, leaving 1 package and 1 MB on disk. pip-audit reported no known vulnerabilities.
What does msgspec need to run?
Python >=3.10, and a platform wheel with compiled extensions. In our run import msgspec succeeded in 0.15s, and the package ships py.typed for type checkers.
msgspec or pydantic: which should you use?
pydantic: Use it for richer validators, aggregated errors, settings, FastAPI, and a larger extension ecosystem. msgspec 0.21.1 installed as a 1 MB compiled package in 0.2 seconds on our sandbox and imported in 0.15 seconds, making it a lean typed-codec candidate.
When should you not use msgspec?
A form or public API must return all invalid fields in one response. msgspec reports the first decode validation error.
Use it if
- Profiles show that decoding and validating JSON or MessagePack consumes meaningful CPU in a queue, RPC, or API boundary.
- One annotated message type should drive several codecs, in-memory conversion, and JSON Schema output.
- Compact slot-based records and reusable typed decoders fit the service better than feature-heavy application models.
- Validation can be expressed through types, bounds, patterns, tagged unions, and limited `__post_init__` checks.
- A form or public API must return all invalid fields in one response. msgspec reports the first decode validation error.
- Models depend on many custom validators, settings sources, ORM helpers, or FastAPI's default schema flow. Pydantic has the broader application-model layer.
- Deployment cannot use a compatible wheel and has no compiler toolchain. The installed distribution contains a native `.so` extension.
- Callers routinely mutate model attributes after construction and expect encoding to revalidate them. Encoding does not repeat typed decode validation.
- The project requires a settled 1.x compatibility promise. msgspec remains on 0.x, and 0.21 changed when replacement calls `__post_init__`.
Setup reality
We installed msgspec 0.21.1 in a clean Python 3.12 Bookworm container in 0.2 seconds. It left 1 package and 1 MB on disk; pip-audit found 0 known vulnerabilities. The measured metadata declares 3 direct dependencies and no recognized license value. Python 3.10 or newer is required. The wheel includes compiled .so code and py.typed, and import msgspec completed in 0.15 seconds.
JSON and MessagePack use the main codec implementation. YAML and TOML need their optional supporting packages, so the minimal install does not guarantee those calls work. Struct is slot-based and is not a dataclass: undeclared attributes fail, mutable defaults belong in msgspec.field(default_factory=...), and dataclass helpers are the wrong conversion tools. Use msgspec.structs, msgspec.convert, or msgspec.to_builtins instead.
Typed decoding validates incoming data. Encoding assumes the object is already valid, so later assignment of a wrong runtime type can still reach the wire. Strict mode rejects coercions such as a numeric string into an integer. strict=False suits boundaries that can only supply strings, including environment values, but it is a poor default for JSON request bodies. Validation stops at the first error, which keeps the hot path short and limits form-style feedback.
Create Encoder and typed Decoder objects once for frequently used schemas. Version 0.21.1 preserves ValidationError and DecodeError raised inside dec_hook instead of nesting them inside another validation error. In 0.21, both msgspec.structs.replace and copy.replace invoke __post_init__; replacement can therefore repeat validation, logging, or other side effects placed there. Test source builds separately because the 1 MB wheel result depends on receiving a compatible compiled artifact.
Patterns
Decode JSON into a typed Struct define-struct
import msgspec
class User(msgspec.Struct):
name: str
email: str | None = None
groups: set[str] = msgspec.field(default_factory=set)
user = msgspec.json.decode(
b'{"name":"alice","groups":["admin"]}',
type=User,
)Passing `type=User` performs typed validation. Mutable collection defaults need `default_factory`.
Keep typed codecs at module scope reuse-codecs
import msgspec
class Event(msgspec.Struct):
id: int
kind: str
DECODER = msgspec.json.Decoder(Event)
ENCODER = msgspec.json.Encoder()
def handle(raw: bytes) -> bytes:
event = DECODER.decode(raw)
return ENCODER.encode(event)Recreating a typed `Decoder` inside each request repeats schema preparation and discards part of its hot-path benefit.
Add numeric and string constraints constrain-fields
from typing import Annotated
import msgspec
from msgspec import Meta
Port = Annotated[int, Meta(ge=1, le=65535)]
Slug = Annotated[str, Meta(pattern=r"^[a-z0-9-]+$", max_length=64)]
class Service(msgspec.Struct):
slug: Slug
port: Port = 8080Regex constraints search unless the pattern includes anchors; use `^` and `$` when the whole string must match.
Rename fields and reject unknown keys configure-wire-fields
import msgspec
class ApiUser(
msgspec.Struct,
rename="camel",
omit_defaults=True,
forbid_unknown_fields=True,
frozen=True,
kw_only=True,
):
first_name: str
last_name: str
is_admin: bool = FalseUnknown keys are ignored by default. `forbid_unknown_fields=True` turns them into a validation error.
Select a Struct through a wire tag decode-tagged-union
import msgspec
class Created(msgspec.Struct, tag="created", tag_field="type"):
id: int
class Deleted(msgspec.Struct, tag="deleted", tag_field="type"):
id: int
reason: str
Event = Created | Deleted
event = msgspec.json.decode(raw, type=Event)Every union member must use the same `tag_field`; msgspec does not guess between similar untagged Struct shapes.
Represent an omitted patch field distinguish-missing-null
import msgspec
from msgspec import UNSET, UnsetType
class UserPatch(msgspec.Struct, omit_defaults=True):
name: str | UnsetType = UNSET
bio: str | None | UnsetType = UNSET
patch = msgspec.json.decode(b'{"bio":null}', type=UserPatch)
assert patch.name is UNSET
assert patch.bio is None`UNSET` means absent and `None` means explicit null; compare with the singleton so false values remain valid updates.
Convert an unsupported Python type write-custom-hooks
from ipaddress import IPv4Address
import msgspec
def enc_hook(obj):
if isinstance(obj, IPv4Address):
return str(obj)
raise NotImplementedError
def dec_hook(type_, obj):
if type_ is IPv4Address:
return IPv4Address(obj)
raise NotImplementedError
encoder = msgspec.json.Encoder(enc_hook=enc_hook)
decoder = msgspec.json.Decoder(IPv4Address, dec_hook=dec_hook)Raise `NotImplementedError` for types the hook does not own. Version 0.21.1 no longer wraps hook validation errors a second time.
Coerce an in-memory settings mapping convert-python-values
import msgspec
class Config(msgspec.Struct):
host: str
port: int
debug: bool = False
raw = {"host": "localhost", "port": "8080", "debug": "true"}
config = msgspec.convert(raw, Config, strict=False)
plain = msgspec.to_builtins(config)Use `strict=False` only where string coercion is intentional. Strict mode is safer for already typed or decoded API data.
Produce schema components for Structs generate-json-schema
import msgspec
class Item(msgspec.Struct):
sku: str
quantity: int = 1
schemas, components = msgspec.json.schema_components(
[Item, list[Item]],
ref_template="#/components/schemas/{name}",
)Checks implemented only in `__post_init__` cannot appear in generated JSON Schema.
Keep an envelope payload as Raw bytes defer-payload-decoding
import msgspec
class Envelope(msgspec.Struct):
topic: str
payload: msgspec.Raw
class Click(msgspec.Struct):
url: str
envelope = msgspec.json.decode(raw_message, type=Envelope)
if envelope.topic == "click":
click = msgspec.json.decode(envelope.payload, type=Click)A `Raw` view keeps the source buffer alive. Copy it before retaining a small payload from a much larger message.
Decode optional TOML support decode-toml
# Install msgspec with its TOML extra first.
import msgspec
class Settings(msgspec.Struct):
name: str
workers: int = 4
with open("config.toml", "rb") as file:
settings = msgspec.toml.decode(file.read(), type=Settings)TOML and YAML need optional support packages; JSON and MessagePack use msgspec's main codec path.
Copy a Struct with one changed field replace-struct
import msgspec
from msgspec import structs
class Point(msgspec.Struct, frozen=True):
x: int
y: int
point = Point(1, 2)
updated = structs.replace(point, y=9)
assert structs.asdict(updated) == {"x": 1, "y": 9}Version 0.21 invokes `__post_init__` on the replacement value, so side effects there run again.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydantic | PyPI | Use it for richer validators, aggregated errors, settings, FastAPI, and a larger extension ecosystem. |
| orjson | PyPI | Use it when fast JSON bytes are the goal and typed validation lives elsewhere. |
| cattrs | PyPI | Use it to structure existing attrs classes or dataclasses through configurable hooks. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

