Context
Pydantic and marshmallow solve the same underlying problem: turning loose data (a request's JSON, a dict coming from the database, environment variables) into validated, typed structures, and then back into something serializable. What separates the two is how you declare the schema. Pydantic starts from Python's native type hints: you write a class that inherits from BaseModel, annotate each field with its expected type, and the type hint itself becomes the validation rule. In v2, that validation runs on a compiled Rust core (pydantic-core), which puts the library in a performance range pure Python can't reach. v2 was a large rewrite precisely to move the validation guts into that core.
marshmallow takes the opposite, explicit path: you declare a Schema class and list each field as an object (fields.Str(), fields.Int(), fields.Nested(...)), without relying on type hints, and (de)serialization happens in separate methods, load() and dump(). It's an older, more mature library, designed from the start to be independent of any framework.
That's the decisive variable: do you want the schema to be Python's own type hint (Pydantic) or an explicit declaration independent of the type system (marshmallow)? Almost everything else (speed, IDE integration, how it fits into frameworks) follows from that choice.
When to choose Pydantic
Pydantic makes the most sense when your data already lives as typed objects in your code and you want validation and typing to be the same thing. It's the default choice for any FastAPI project: the framework uses Pydantic as the basis for validating the request and serializing the response, so the model you write for the API is the same one the type checker sees. mypy and IDE autocomplete understand the model with no plugin, because there's no runtime magic: the fields are annotated attributes of a class.
from pydantic import BaseModel, EmailStr, field_validator
class User(BaseModel):
id: int
name: str
email: EmailStr
is_active: bool = True
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("name must not be blank")
return v
user = User(id="42", name="Ada", email="ada@example.com")
# id arrived as "42" (str) and was coerced to 42 (int)
user.model_dump()
# {'id': 42, 'name': 'Ada', 'email': 'ada@example.com', 'is_active': True}
The example shows the typical v2 behavior: id arrives as the string "42" and is coerced to int, validation runs on object construction, and model_dump() handles serialization back into a dict. Errors come in a structured ValidationError, with the field's path and the error type, easy to return as an API response. For configuration, the sibling package pydantic-settings loads environment variables and .env files straight into a typed BaseSettings, replacing the classic os.environ scattered through the code. At version 2.13.4, with more than 1.29 billion downloads per week on PyPI, Pydantic is today the default validation layer of modern Python, driven in large part by FastAPI's adoption.
When to choose marshmallow
marshmallow shines when you want an explicit (de)serialization layer, independent of both the framework and the type system. It assumes nothing about how your data lives in code: you describe the external shape (the payload) in a Schema, and the input and output directions stay deliberately separate. load() takes raw data and validates/deserializes it; dump() takes your objects and serializes them. That separation is useful when the two directions aren't symmetric, for example a write-only field like a password, which comes in on load but never goes out on dump.
from marshmallow import Schema, fields, validate, ValidationError
class UserSchema(Schema):
id = fields.Int(required=True)
name = fields.Str(required=True, validate=validate.Length(min=1))
email = fields.Email(required=True)
is_active = fields.Bool(load_default=True)
schema = UserSchema()
try:
user = schema.load({"id": 42, "name": "Ada", "email": "ada@example.com"})
except ValidationError as err:
print(err.messages) # {'field': ['error message']}
# dump() goes the other way, from the object back to a serializable dict
payload = schema.dump(user)
The Schema above knows nothing about type hints: each field is an object declared at runtime, which makes it less visible to mypy and autocomplete than a BaseModel, but also fully decoupled from any framework. That's why marshmallow is common in the Flask world (via flask-marshmallow) and in any stack that doesn't revolve around FastAPI. Validation errors arrive in ValidationError.messages, a dict of messages per field. At version 4.3.0, with roughly 154 million downloads per week, marshmallow carries years on the road and a production-tested ecosystem (marshmallow-sqlalchemy, Flask integration, APISpec), the kind of maturity you can't improvise.
Verdict
Reduced to its essentials, the choice has one central variable: how the schema relates to Python's type system. If your data already lives as typed objects, or the project runs on FastAPI, Pydantic is the obvious fit. v2 brought the Rust core that settled the historical performance critique, the models are real type hints (so mypy and the IDE understand everything without a plugin), and pydantic-settings closes the typed-configuration use case. It's that combination, more than any single number, that made Pydantic the ecosystem's default.
marshmallow remains the right pick when you want an explicit, framework-agnostic (de)serialization layer, when the load and dump directions are asymmetric, or when the project already runs a mature Flask stack with flask-marshmallow and tested integrations. The traction gap is real (1.29 billion against 154 million downloads per week), but it mostly reflects the FastAPI effect pulling Pydantic along, not a weakness in marshmallow. Migrating a Schema that already works just to gain type hints rarely pays off; starting a new FastAPI service on marshmallow is rowing against the framework's current. Choose by how you want to declare the schema, not by the download count.