msgspec vs Pydantic¶
FasterAPI uses msgspec for validation and serialization instead of Pydantic. This page explains the design philosophy behind each library, where they differ, when each one shines, and how to migrate existing Pydantic code.
Philosophy¶
| msgspec | Pydantic v2 | |
|---|---|---|
| Primary goal | Zero-copy serialization + validation in C | Developer-friendly validation with rich error messages |
| Schema definition | msgspec.Struct (immutable, __slots__-based) |
pydantic.BaseModel (mutable, supports __init__ customisation) |
| Validation timing | At decode/encode boundary only | On attribute assignment (with model_validate) |
| Type coercion | Strict by default (no silent coercion) | Lenient by default ("42" → 42) |
| Error detail | Compact, path-based | Verbose, human-readable with loc/msg/type |
| Speed | ~5–10× faster than Pydantic v2 for encode/decode | Fast (Rust core), slower than msgspec |
| Memory | Lower (Structs use __slots__, no instance __dict__) |
Higher (BaseModel has more metadata overhead) |
| Ecosystem | Self-contained | Rich ecosystem (validators, serializers, settings) |
Performance comparison¶
Benchmark on Apple Silicon (Python 3.13, 1 M iterations):
| Operation | msgspec | Pydantic v2 | Speedup |
|---|---|---|---|
| JSON encode | ~1,400,000 ops/s | ~280,000 ops/s | ~5× |
| JSON decode + validate | ~950,000 ops/s | ~180,000 ops/s | ~5× |
| Object construction | ~8,000,000 ops/s | ~1,500,000 ops/s | ~5× |
These numbers are from benchmarks/compare.py. See the
Benchmark Methodology page for reproduction steps.
API comparison¶
Defining schemas¶
Decoding / validating¶
import msgspec
json_bytes = b'{"id": 1, "name": "Alice", "email": "alice@example.com"}'
# Decode JSON bytes directly into a typed Struct
user = msgspec.json.decode(json_bytes, type=User)
print(user.name) # Alice
# Validate from a dict (Python object)
user = msgspec.convert({"id": 1, "name": "Alice", "email": "a@e.com"}, User)
Encoding / serializing¶
Field customisation¶
Custom validators¶
import msgspec
class Email(str):
"""Custom type that validates email format."""
# msgspec uses Python's __get_validators__ protocol for custom types
# For complex validation, use post-decode processing or a custom Encoder/Decoder
class SignupRequest(msgspec.Struct):
email: str
password: str
def __post_init__(self):
if "@" not in self.email:
raise ValueError(f"Invalid email: {self.email}")
if len(self.password) < 8:
raise ValueError("Password must be at least 8 characters")
from pydantic import BaseModel, field_validator, EmailStr
class SignupRequest(BaseModel):
email: EmailStr # built-in email validator
password: str
@field_validator("password")
@classmethod
def password_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
Key behavioural differences¶
Type coercion¶
msgspec is strict — it raises on type mismatches; Pydantic v2 is lenient by default:
import msgspec, json
class Payload(msgspec.Struct):
count: int
# OK — integer
msgspec.json.decode(b'{"count": 42}', type=Payload)
# Raises msgspec.ValidationError — string not accepted for int
msgspec.json.decode(b'{"count": "42"}', type=Payload)
Pydantic would silently coerce "42" → 42.
Mutability¶
msgspec Structs are immutable by default:
class Point(msgspec.Struct):
x: float
y: float
p = Point(1.0, 2.0)
p.x = 3.0 # AttributeError — Struct is frozen
# For a mutable Struct:
class MutablePoint(msgspec.Struct, frozen=False):
x: float
y: float
Pydantic BaseModel instances are mutable by default.
Inheritance¶
# msgspec — single inheritance only, no field override
class Base(msgspec.Struct):
id: int
class Child(Base):
name: str # adds a field
# Pydantic — supports multiple inheritance and field override
class Child(Base):
name: str
id: int = 0 # overrides parent field
JSON null vs missing field¶
class Item(msgspec.Struct):
name: str
tag: str | None = None
# Both parse correctly in msgspec:
msgspec.json.decode(b'{"name": "x"}', type=Item) # tag = None
msgspec.json.decode(b'{"name": "x", "tag": null}', type=Item) # tag = None
# To distinguish missing from null, use msgspec.NODEFAULT sentinel:
import msgspec
MISSING = msgspec.NODEFAULT
class Item(msgspec.Struct):
name: str
tag: str | msgspec.UnsetType = msgspec.NODEFAULT
When to choose each¶
Choose msgspec when:¶
- Maximum throughput is a priority (high-req/s APIs, real-time systems)
- Schemas are relatively simple (CRUD entities, event payloads)
- You want zero external dependencies beyond msgspec itself
- You're already using FasterAPI (it's the native validation layer)
Choose Pydantic when:¶
- You need complex validators (
@field_validator,@model_validator) - You rely on Pydantic-ecosystem libraries (pydantic-settings, SQLModel, FastAPI)
- Your team is already experienced with Pydantic
- You need strict/lenient mode control per-field
- You need
model_dump(exclude_unset=True)semantics
Using both in the same project¶
FasterAPI uses msgspec for route handler validation. You can still use Pydantic for settings or internal domain models:
# settings.py — Pydantic-settings for type-safe env vars
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
secret_key: str
debug: bool = False
class Config:
env_file = ".env"
settings = Settings()
# routes.py — msgspec for request/response schemas
import msgspec
from FasterAPI import Faster
app = Faster()
class CreateUser(msgspec.Struct):
username: str
password: str
@app.post("/users")
async def create_user(body: CreateUser):
...
Migration patterns¶
From Pydantic BaseModel to msgspec Struct¶
| Pydantic | msgspec |
|---|---|
class M(BaseModel) |
class M(msgspec.Struct) |
Field(alias="x") |
msgspec.field(name="x") |
Field(default_factory=list) |
msgspec.field(default_factory=list) |
model_validate(data) |
msgspec.convert(data, M) |
model_dump() |
msgspec.structs.asdict(obj) |
model_dump_json() |
msgspec.json.encode(obj) |
@field_validator |
__post_init__ method |
Optional[X] / X \| None |
X \| None (same) |
list[X] |
list[X] (same) |
Step-by-step migration¶
- Replace
BaseModelwithmsgspec.Structin schema files. - Replace
Field(...)withmsgspec.field(...)where needed. - Replace
model_validatecalls withmsgspec.convert. - Replace
model_dump/model_dump_jsonwithmsgspec.structs.asdict/msgspec.json.encode. - Move complex validators into
__post_init__or a separate validation function. - Run your tests — msgspec is stricter, so some test inputs may need adjustment.
Next steps¶
- Python Type Hints — the type annotation foundations msgspec builds on.
- Benchmarks Deep Dive — how the performance numbers are measured.
- Request Body — using msgspec Structs in route handlers.