mrkeyoor.com_
Thu 06 Aug 14:58 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5Still 0.x after five years and classified as Beta, which nominally means anything can move, but in practice the core encode, decode, and Struct API has barely changed since 0.9. The breaks that do land are small and listed in the release notes: 0.19 removed the deprecated from_builtins and changed encode_into buffer behaviour, 0.21 started calling __post_init__ from structs.replace. Treat it as stable with a version pin rather than as a library that will churn
Docs5/5msgspec.dev has a page per concept (structs, constraints, supported types, extending, JSON schema, inspecting types) with runnable examples, a published benchmark methodology rather than just a chart, comparison pages against pydantic and other libraries, and as of 0.21.1 a porting guide for people coming from orjson. The supported-types table alone answers most questions before they become issues
Maintenance3/5Effectively a single-maintainer C extension. Repo pushed 2026-07-29 and the recent work is real (Python 3.14 including free-threaded mode, Windows arm64 wheels, several memory leak fixes), but releases arrive in bursts with long gaps: eleven months between 0.19.0 and 0.20.0. 186 open issues (211 counting PRs). Nothing is abandoned, but do not expect a bug report to turn into a release quickly
Ecosystem3/5About 10.3M downloads a week, most of it arriving as a transitive dependency rather than a direct choice. Litestar uses it natively and it shows up under several other frameworks and SDKs, but there is no plugin ecosystem, no settings library, no ORM integration, and nothing like pydantic's volume of examples and answers. When you hit an edge case you will be reading the source or the issue tracker

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
Skip it if

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 encode

This 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

PackageRegistryPick it when
pydanticPyPIYou need custom validators, all errors collected at once, settings management, or anything in the FastAPI world, and you can pay the extra decode time
orjsonPyPIYou 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
cattrsPyPIYou 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