msgspec
msgspec is a serialization and validation library written in C. You declare a schema by subclassing msgspec.Struct and annotating the fields with ordinary Python types, then msgspec.json.encode turns instances into bytes and msgspec.json.decode turns bytes back into validated instances. The same schema works for MessagePack, YAML, and TOML through msgspec.msgpack, msgspec.yaml, and msgspec.toml. The unusual part is that validation is folded into the decoder rather than run afterwards: the parser checks types as it walks the input, so there is no intermediate dict and no second pass. A Struct is a compact __slots__ class with generated __init__, __repr__, and __eq__, so it behaves much like a dataclass while allocating less and comparing faster. The core library has no required dependencies; YAML and TOML are optional extras.
The fastest way to get validated typed objects out of bytes in Python, and the zero-dependency install makes it easy to justify in a library. Choose it when throughput is the constraint and your validation is really just types plus bounds; choose pydantic the moment you need custom validators or all the errors at once.
Use it if
- You are decoding a lot of JSON on a hot path (an RPC server, a queue consumer, a log pipeline) and the decode plus validate step shows up in a profile, which is the case msgspec is built for
- You want typed request and response objects without the weight of a full validation framework: annotations, a Struct subclass, and nothing else in your dependency tree
- You need the same schema over more than one wire format, for example JSON on the public API and MessagePack between internal services, because encode and decode take the identical type
- You are on Litestar, or writing your own framework, and want the serialization layer to be something you chose rather than something the framework chose for you
- You need a JSON Schema document for types you already declared: msgspec.json.schema walks the annotations and emits one, so the OpenAPI spec and the runtime validator cannot drift apart
- You need validation logic that is not a type or a simple constraint. msgspec.Meta covers ge, le, min_length, max_length, pattern, and multiple_of, and that is the entire declarative surface. There is no equivalent of pydantic's field_validator or model_validator, so cross-field rules go in __post_init__ and you write them by hand
- You need every error at once. Decoding raises ValidationError on the first bad field and stops, so a form submission with four problems reports one. Pydantic collects all of them, and for anything user-facing that difference matters more than the speed
- You are on FastAPI. FastAPI's request parsing, response models, dependency injection, and generated OpenAPI are all pydantic. Bolting msgspec on means custom route classes and giving up the automatic docs, and at that point Litestar is the honest answer
- You want pydantic's surrounding library: BaseSettings for environment config, the validator and serializer plugin ecosystem, SQLModel, and the pile of Stack Overflow answers. msgspec has around 4k stars and a comparatively small amount of third-party material to search when you get stuck
- You depend on the release cadence of a project with more than one maintainer. This is essentially one person's C extension: 0.19.0 landed December 2024, 0.20.0 eleven months later in November 2025, and 0.21.0 in April 2026, with 186 open issues. It is still labelled Development Status 4 - Beta and has been below 1.0 for its whole life
- You need to serialize arbitrary objects. Anything outside the supported type list needs an enc_hook and a dec_hook pair that you write, and unlike pydantic there is no arbitrary_types_allowed escape valve that mostly works
- Your build cannot install wheels. It is a C extension, there is no pure-Python fallback, and an unusual platform or a locked-down environment means compiling it
Setup reality
pip install msgspec and you are done for JSON and MessagePack: no required dependencies, wheels for the usual platforms, Python 3.10 or newer. YAML and TOML are extras (pip install 'msgspec[yaml,toml]'), which pull pyyaml and tomli_w, and forgetting them gives you an ImportError only when you first call msgspec.yaml.decode rather than at import time. The real friction is not installation, it is the mental switch. Structs are not dataclasses: they use __slots__, so you cannot assign an attribute that is not declared, and code that calls dataclasses.asdict on them fails. Use msgspec.structs.asdict, msgspec.structs.replace, and msgspec.to_builtins instead. Mutable defaults raise at class definition time, so lists and dicts need msgspec.field(default_factory=list). Decoding is strict by default: the string "5" will not become the integer 5 unless you pass strict=False, which surprises anyone coming from pydantic v1. Encoding a Struct never validates, only decoding does, so a field you assigned the wrong type to at runtime sails out onto the wire. And building a fresh msgspec.json.Decoder(type=Foo) inside a request handler throws away most of the performance you installed the library for, because the decoder compiles the type once at construction; build it at module scope and reuse it.
Patterns
A Struct, encoded and decoded with validationdefine-and-roundtrip
import msgspec
class User(msgspec.Struct):
name: str
email: str | None = None
groups: set[str] = msgspec.field(default_factory=set)
alice = User("alice", groups={"admin"})
buf = msgspec.json.encode(alice)
# b'{"name":"alice","email":null,"groups":["admin"]}'
msgspec.json.decode(buf, type=User)
# User(name='alice', email=None, groups={'admin'})
msgspec.json.decode(b'{"name": 123}', type=User)
# msgspec.ValidationError: Expected `str`, got `int` - at `$.name`Without type=User you get plain dicts and no validation at all, which is the most common way people accidentally benchmark msgspec against orjson and find them similar. Mutable defaults must go through msgspec.field(default_factory=...); writing groups: set[str] = set() raises at class creation time. Encoding never validates: if you assign alice.name = 123 at runtime, encode happily writes it.
Build Encoder and Decoder once, not per requestreuse-the-decoder
import msgspec
class Event(msgspec.Struct):
id: int
kind: str
# module scope: the type is compiled into the decoder here
DECODER = msgspec.json.Decoder(Event)
ENCODER = msgspec.json.Encoder()
def handle(raw: bytes) -> bytes:
event = DECODER.decode(raw) # no per-call setup
return ENCODER.encode(event)
# deterministic key order, for signatures or golden files
STABLE = msgspec.json.Encoder(order="deterministic")
# write into a buffer you own, avoiding an allocation per message
buf = bytearray(256)
ENCODER.encode_into(Event(1, "ping"), buf)msgspec.json.decode(buf, type=Event) constructs a throwaway decoder every call. On small messages that setup cost can be most of the total, so a hot loop should hold module-level Decoder and Encoder objects. Decoders are thread-safe for decoding. encode_into expands the buffer if it is too small rather than raising, and since 0.19 it also expands when the buffer is smaller than the given offset.
Bounds and patterns with Annotated plus Metaconstraints
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 = 8080
tags: Annotated[list[str], Meta(max_length=10)] = msgspec.field(default_factory=list)
msgspec.json.decode(b'{"slug":"api","port":99999}', type=Service)
# ValidationError: Expected `int` <= 65535 - at `$.port`Meta is the whole declarative validation surface: ge, gt, le, lt, multiple_of, min_length, max_length, pattern, tz, plus title and description for the generated JSON schema. pattern is a search, not a full match, so anchor it with ^ and $ yourself. Anything relational, like 'end must be after start', has to live in __post_init__, which msgspec calls after decoding and after msgspec.convert.
Struct config: rename, omit_defaults, frozen, forbid_unknown_fieldsstruct-options
import msgspec
class ApiUser(
msgspec.Struct,
rename="camel", # first_name <-> firstName on the wire
omit_defaults=True, # skip fields still at their default
forbid_unknown_fields=True, # error instead of ignoring extras
frozen=True, # hashable, immutable
kw_only=True,
):
first_name: str
last_name: str
is_admin: bool = False
msgspec.json.encode(ApiUser(first_name="A", last_name="B"))
# b'{"firstName":"A","lastName":"B"}' is_admin omitted
# per-field override when the wire name is not mechanical
class Row(msgspec.Struct):
user_id: int = msgspec.field(name="userID")These are class keyword arguments, not a nested Config class. rename accepts "camel", "pascal", "kebab", "upper", "lower", a dict, or a callable. omit_defaults compares against the default, so a field explicitly set to its default value still disappears from the output, which breaks patch-style APIs; use msgspec.UNSET for those instead. forbid_unknown_fields is off by default, so unknown JSON keys are silently dropped unless you ask otherwise. frozen=True also gives you __hash__.
Decode a discriminated union into the right Structtagged-unions
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
DEC = msgspec.json.Decoder(Event)
DEC.decode(b'{"type":"deleted","id":7,"reason":"spam"}')
# Deleted(id=7, reason='spam')
msgspec.json.encode(Created(1))
# b'{"type":"created","id":1}'tag_field must be identical across every member of the union or msgspec rejects the type at decoder construction. With tag=True the tag value defaults to the class name. This is the one union form that decodes in a single pass; an untagged union of two Structs is rejected because msgspec will not guess. Unions of a Struct with None, or of unambiguous scalar types, are fine without tags.
Tell 'absent' apart from 'explicitly null' for PATCH bodiesoptional-vs-unset
import msgspec
from msgspec import UNSET, UnsetType
class UserPatch(msgspec.Struct, omit_defaults=True):
name: str | UnsetType = UNSET
bio: str | None | UnsetType = UNSET
p = msgspec.json.decode(b'{"bio": null}', type=UserPatch)
p.name is UNSET # True -> field absent, leave it alone
p.bio # None -> caller asked to clear it
msgspec.json.encode(p)
# b'{"bio":null}' UNSET fields are dropped on encodeThis is the reason UNSET exists and there is no other clean way to do it. UNSET fields are omitted on encode regardless of omit_defaults, but you still want omit_defaults for the rest of the struct. Remember to handle UnsetType in whatever consumes the patch; a plain if p.name: treats an empty string and UNSET the same way, so compare with is UNSET explicitly.
enc_hook and dec_hook for types msgspec does not knowcustom-types
import msgspec
from ipaddress import IPv4Address
from typing import Any, Type
def enc_hook(obj: Any) -> Any:
if isinstance(obj, IPv4Address):
return str(obj)
raise NotImplementedError(f"cannot encode {type(obj)}")
def dec_hook(typ: Type, obj: Any) -> Any:
if typ is IPv4Address:
return IPv4Address(obj)
raise NotImplementedError(f"cannot decode {typ}")
class Host(msgspec.Struct):
ip: IPv4Address
ENC = msgspec.json.Encoder(enc_hook=enc_hook)
DEC = msgspec.json.Decoder(Host, dec_hook=dec_hook)
DEC.decode(ENC.encode(Host(IPv4Address("10.0.0.1"))))enc_hook is only called for types msgspec cannot already handle, so do not try to override the encoding of int or datetime with it. dec_hook receives the annotated type and the already-decoded builtin, which means the JSON must be a shape msgspec understands first. Raise NotImplementedError rather than returning None for unhandled types; that is what msgspec turns into a useful error. Since 0.21.1 a ValidationError raised inside dec_hook propagates instead of being wrapped again.
Validate objects you already have in memoryconvert-and-to-builtins
import msgspec
class Config(msgspec.Struct):
host: str
port: int
debug: bool = False
# a dict from anywhere: yaml, a database row, os.environ
raw = {"host": "localhost", "port": "8080", "debug": "true"}
msgspec.convert(raw, Config)
# ValidationError: Expected `int`, got `str` - at `$.port`
msgspec.convert(raw, Config, strict=False)
# Config(host='localhost', port=8080, debug=True)
msgspec.to_builtins(Config("localhost", 8080))
# {'host': 'localhost', 'port': 8080, 'debug': False}convert is the in-memory counterpart of decode and takes the same strict flag; strict=False is what you want for environment variables and query strings, where everything arrives as a string. to_builtins is the reverse and gives you JSON-ready primitives without serializing, which is what you pass to a template or a logger. from_builtins was the old name for convert and was removed in 0.19.
Generate JSON Schema from the same typesjson-schema
import msgspec
from typing import Annotated
from msgspec import Meta
class Item(msgspec.Struct):
sku: Annotated[str, Meta(pattern=r"^[A-Z]{3}-\d+$", description="Stock code")]
qty: Annotated[int, Meta(ge=1)] = 1
msgspec.json.schema(Item)
# {'$ref': '#/$defs/Item', '$defs': {'Item': {...}}}
# several types sharing one $defs block, for an OpenAPI components section
schemas, components = msgspec.json.schema_components(
[Item, list[Item]], ref_template="#/components/schemas/{name}"
)The schema is generated from the annotations the decoder actually uses, so it cannot drift from runtime behaviour. Meta's title and description feed straight into it. ref_template was added to msgspec.json.schema in 0.21.0 (schema_components already had it), so pin at least 0.21 if you need it on the single-type call. Anything you validate in __post_init__ is invisible to the generated schema.
Skip decoding a payload you are only routingraw-and-partial-decoding
import msgspec
class Envelope(msgspec.Struct):
topic: str
payload: msgspec.Raw # left as bytes, not parsed
class Click(msgspec.Struct):
url: str
ENVELOPE = msgspec.json.Decoder(Envelope)
CLICK = msgspec.json.Decoder(Click)
env = ENVELOPE.decode(raw_message)
if env.topic == "click":
click = CLICK.decode(env.payload) # decode only what you need
# building one: Raw wraps already-encoded bytes
msgspec.json.encode(Envelope("click", msgspec.Raw(b'{"url":"/x"}')))Raw is a view over the original buffer, so holding one keeps the whole message alive in memory; call .copy() if you are storing it. It is validated as well-formed JSON but nothing else, so a router that never decodes the payload never notices a malformed body. Useful for fan-out services, message brokers, and any case where most messages get forwarded rather than inspected.
MessagePack, YAML, and TOML from the same Structother-formats
# pip install 'msgspec[yaml,toml]'
import msgspec
class Settings(msgspec.Struct):
name: str
workers: int = 4
with open("config.toml", "rb") as f:
cfg = msgspec.toml.decode(f.read(), type=Settings)
msgspec.yaml.encode(cfg) # b'name: api\nworkers: 4\n'
msgspec.msgpack.encode(cfg) # compact binary, same schema
MP = msgspec.msgpack.Decoder(Settings)
MP.decode(msgspec.msgpack.encode(cfg))Only json and msgpack are pure C with streaming decoders; yaml and toml wrap pyyaml and the standard library tomllib, so they are ordinary-speed and only make sense for config files. The extras are not installed by default and the ImportError arrives at first call, not at import. msgpack supports more types natively than JSON, including bytes and integer keys, so a Struct that round-trips through msgpack may not round-trip through JSON.
asdict, replace, and comparing Structsstruct-utilities
import msgspec
from msgspec import structs
class Point(msgspec.Struct, frozen=True, order=True):
x: int
y: int
p = Point(1, 2)
structs.asdict(p) # {'x': 1, 'y': 2}
structs.astuple(p) # (1, 2)
structs.replace(p, y=9) # Point(x=1, y=9)
[f.name for f in structs.fields(Point)]
sorted([Point(2, 1), Point(1, 5)]) # order=True gives < <= > >=
hash(p) # frozen=True gives __hash__dataclasses.asdict, dataclasses.fields, and dataclasses.replace all fail on a Struct because it is not a dataclass; use the msgspec.structs versions. As of 0.21.0 structs.replace (and copy.replace on 3.13+) calls __post_init__ on the new instance, which is a behaviour change from 0.20 and will re-run your validation. astuple depends on field declaration order, so reordering fields silently changes its output.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydantic | PyPI | You need custom validators, all errors collected at once, settings management, or anything in the FastAPI world, and you can pay the extra decode time |
| orjson | PyPI | You only want fast JSON in and out of dicts with no schema at all, and adding a type layer would be ceremony you do not need |
| cattrs | PyPI | You already model with attrs or dataclasses and want pure-Python structuring and unstructuring hooks you can override per type, with no C extension in the build |