import ComparisonTable from ’../../components/ComparisonTable.astro’;
Data validation and serialization are foundational to Python APIs and data pipelines. Pydantic and Marshmallow are the two dominant libraries, representing different philosophies: Pydantic uses Python type hints directly, Marshmallow uses a separate schema class definition.
Quick Verdict
Choose Pydantic if: You’re using FastAPI, building modern Python 3.9+ services, or want maximum performance with IDE support.
Choose Marshmallow if: You need Flask/Django integration, have complex schema inheritance hierarchies, or are maintaining existing Marshmallow codebases.
Feature Comparison
<ComparisonTable headers={[“Feature”, “Pydantic v2”, “Marshmallow 3”]} rows={[ [“Definition style”, “Python type hints”, “Separate Schema class”], [“Validation speed”, “5-50x faster (Rust core)”, “Pure Python”], [“IDE support”, “Excellent (native types)”, “Limited”], [“FastAPI integration”, “Native (built-in)”, “Via plugin”], [“Django integration”, “Via third-party”, “Native (marshmallow-django)”], [“Custom validators”, “Field validators + model validators”, “validate= / @validates”], [“Nested models”, “Native type annotation”, “Nested() field”], [“Serialization”, “model_dump(), model_dump_json()”, “schema.dump()”], [“JSON Schema”, “model.model_json_schema()”, “marshmallow-jsonschema plugin”], [“Settings management”, “pydantic-settings”, “No equivalent”], ]} />
Schema Definition
Pydantic — uses Python type hints:
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional
from datetime import datetime
from enum import Enum
class UserRole(str, Enum):
admin = "admin"
user = "user"
moderator = "moderator"
class Address(BaseModel):
street: str
city: str
country: str = "US"
postal_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
class User(BaseModel):
id: int
username: str = Field(min_length=3, max_length=50)
email: EmailStr
role: UserRole = UserRole.user
age: Optional[int] = Field(None, ge=0, le=120)
address: Optional[Address] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
return v.lower()
# Usage
user = User(
id=1,
username="Alice123",
email="[email protected]",
role="admin",
age=28,
address={"street": "123 Main St", "city": "Boston", "postal_code": "02134"}
)
print(user.model_dump())
# {'id': 1, 'username': 'alice123', 'email': '[email protected]', ...}
print(user.model_dump_json())
# '{"id":1,"username":"alice123","email":"[email protected]",...}'
Marshmallow — separate schema class:
from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
from typing import Optional
from datetime import datetime
class AddressSchema(Schema):
street = fields.String(required=True)
city = fields.String(required=True)
country = fields.String(load_default="US")
postal_code = fields.String(
required=True,
validate=validate.Regexp(r"^\d{5}(-\d{4})?$", error="Invalid postal code")
)
class UserSchema(Schema):
id = fields.Integer(required=True)
username = fields.String(required=True, validate=validate.Length(min=3, max=50))
email = fields.Email(required=True)
role = fields.String(
load_default="user",
validate=validate.OneOf(["admin", "user", "moderator"])
)
age = fields.Integer(allow_none=True, validate=validate.Range(min=0, max=120))
address = fields.Nested(AddressSchema, allow_none=True)
created_at = fields.DateTime(load_default=datetime.utcnow)
@validates("username")
def validate_username(self, value):
if not value.isalnum():
raise ValidationError("Username must be alphanumeric")
return value.lower()
@post_load
def normalize_username(self, data, **kwargs):
data["username"] = data["username"].lower()
return data
# Usage
schema = UserSchema()
result = schema.load({
"id": 1,
"username": "Alice123",
"email": "[email protected]",
"role": "admin",
"age": 28,
})
# Returns a dict
# Serialization (dump)
output = schema.dump(result)
Key difference: Pydantic gives you a class instance with type-safe attributes. Marshmallow returns a dict by default (unless you use @post_load to return an object).
Validation and Error Handling
Pydantic errors:
from pydantic import ValidationError
try:
user = User(
id="not-a-number",
username="a", # too short
email="not-an-email",
)
except ValidationError as e:
print(e.errors())
# [
# {'type': 'int_parsing', 'loc': ('id',), 'msg': 'Input should be a valid integer', ...},
# {'type': 'string_too_short', 'loc': ('username',), 'msg': 'String should have at least 3 characters', ...},
# {'type': 'value_error', 'loc': ('email',), 'msg': 'value is not a valid email address', ...}
# ]
Marshmallow errors:
from marshmallow import ValidationError
schema = UserSchema()
try:
schema.load({
"id": "not-a-number",
"username": "a",
"email": "not-an-email",
})
except ValidationError as e:
print(e.messages)
# {
# 'id': ['Not a valid integer.'],
# 'username': ['Length must be between 3 and 50.'],
# 'email': ['Not a valid email address.']
# }
FastAPI Integration
Pydantic is native to FastAPI:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class CreateUserRequest(BaseModel):
username: str
email: str
role: str = "user"
class UserResponse(BaseModel):
id: int
username: str
email: str
@app.post("/users", response_model=UserResponse)
async def create_user(user: CreateUserRequest):
# FastAPI automatically validates request body
# and serializes response using Pydantic
db_user = await db.create_user(user.model_dump())
return db_user # Automatically validated against UserResponse
FastAPI’s automatic OpenAPI docs, validation, and serialization all use Pydantic natively.
Marshmallow with Flask:
from flask import Flask, request, jsonify
from marshmallow import ValidationError
app = Flask(__name__)
schema = UserSchema()
@app.route("/users", methods=["POST"])
def create_user():
try:
data = schema.load(request.json)
except ValidationError as e:
return jsonify({"errors": e.messages}), 400
user = create_user_in_db(data)
return jsonify(schema.dump(user)), 201
More manual but gives more control over the request/response cycle.
Performance (Pydantic v2)
Pydantic v2 was rewritten in Rust (via pydantic-core):
import timeit
from pydantic import BaseModel
class Item(BaseModel):
id: int
name: str
price: float
tags: list[str] = []
# Benchmark: 10,000 validations
def pydantic_validation():
Item(id=1, name="Widget", price=9.99, tags=["electronics"])
time = timeit.timeit(pydantic_validation, number=10000)
# Pydantic v2: ~0.8s
# Marshmallow: ~4.5s
# Pydantic v1: ~3.2s
Pydantic v2 is typically 5-20x faster than Marshmallow for common validation tasks.
Settings Management (Pydantic Only)
Pydantic’s ecosystem includes pydantic-settings for application configuration:
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
app_name: str = "My App"
database_url: str
secret_key: str
debug: bool = False
max_connections: int = 10
class Config:
env_file = ".env"
env_prefix = "APP_" # APP_DATABASE_URL, APP_SECRET_KEY, etc.
@lru_cache
def get_settings() -> Settings:
return Settings()
# Usage
settings = get_settings()
print(settings.database_url) # From environment or .env file
Marshmallow has no equivalent — you’d use python-decouple or dynaconf instead.
When to Choose Each
Choose Pydantic:
- FastAPI applications (non-negotiable)
- Modern Python projects (3.9+)
- Performance-critical validation paths
- When IDE type checking matters (mypy, pyright)
- Data pipelines and AI/ML projects (LangChain uses Pydantic)
- Settings/configuration management
Choose Marshmallow:
- Flask applications with existing Marshmallow codebase
- Complex schema inheritance hierarchies (Marshmallow’s Schema inheritance is more flexible)
- Django REST Framework (marshmallow-dataclasses or django-rest-marshmallow)
- When you need Schema as a separate reusable object from your domain model
Bottom Line
Pydantic v2 is the modern standard for Python data validation. The performance gain from the Rust core, native type hint integration, and FastAPI’s adoption make it the default choice for new Python projects. Marshmallow remains relevant for Flask ecosystems and complex inheritance scenarios. If you’re starting new, use Pydantic.