marshmallow
marshmallow converts complex Python objects to and from plain data types. You declare a Schema whose class attributes are fields, then call schema.dump(obj) to turn an object into JSON-ready primitives, or schema.load(data) to validate incoming data and turn it back into Python values. Loading is where the work happens: fields coerce types, run validators, rename keys through data_key, reject unknown keys, and collect every failure into one nested dictionary on a ValidationError instead of stopping at the first problem. It is deliberately framework-agnostic, so the same schema works under Flask, Django, a Kafka consumer, or a plain script, and a large ecosystem (webargs, apispec, marshmallow-sqlalchemy, environs) is built on top of it.
Still the best pick when serialization has to stay decoupled from your models and when you want fine-grained control over the wire format, especially inside the webargs and apispec toolchain. For new services where the validated object is the model, pydantic gives you typing and speed that marshmallow cannot match.
Use it if
- You need serialization and validation to be separate from your model classes, for example one SQLAlchemy model exposed through three different API shapes with different visible fields
- You want every validation error at once as a nested dict keyed by field name, which maps directly onto a 422 response body instead of a single exception message
- Your input and output shapes differ from your internal names: data_key renames keys on the wire, load_only and dump_only split write-side and read-side fields, and @pre_load lets you clean payloads before validation
- You are already in the marshmallow ecosystem, using webargs for request parsing, apispec for OpenAPI generation, or marshmallow-sqlalchemy to derive schemas from ORM models
- You want your validated data to be a typed object your editor understands: marshmallow load() hands you a plain dict unless you write a @post_load hook, while pydantic gives you a typed model instance for free and mypy can check it
- Validation sits on a hot path: marshmallow is pure Python with a function call per field per record, so it is far slower than pydantic v2 (Rust core) or msgspec for high-volume decoding
- You are on 3.x and cannot absorb the 4.0 changes: implicit field creation via the fields and additional Meta options is gone, Schema.context was removed in favour of contextvars, the default and missing keyword arguments are gone (use dump_default and load_default), pass_many was renamed pass_collection, decorator arguments are keyword-only, and custom validators must raise ValidationError instead of returning False
- You depend on a marshmallow plugin that has not been updated: check that your webargs, apispec, flask-marshmallow or marshmallow-sqlalchemy version supports marshmallow 4 before upgrading, or you will be pinned to 3.x anyway
- Your data is already typed Python objects you control end to end. attrs or dataclasses plus cattrs covers structuring and unstructuring with less ceremony than a parallel schema class per model
Setup reality
pip install marshmallow is about as easy as it gets: pure Python, requires Python 3.10 or newer, and no third-party dependencies at all on 3.11+ (older Pythons pull in typing-extensions and a fromisoformat backport). The cost is in the shape of the code, not the install. Every model needs a parallel Schema class, so field definitions live in two places and drift. load() returns a dict, so you write a @post_load hook on every schema that should produce an object. Unknown keys raise by default, which breaks callers that send extra fields until you set unknown = EXCLUDE in class Meta. Errors arrive as a nested dict of lists on ValidationError.messages that you have to translate into whatever your API returns. And because marshmallow 4 dropped Schema.context, code that passed request state into fields has to move to marshmallow.experimental.context.Context or contextvars.
Patterns
Declare a schema and serialize an objectdefine-schema-and-dump
from datetime import date
from marshmallow import Schema, fields
class AlbumSchema(Schema):
title = fields.Str()
release_date = fields.Date()
plays = fields.Int()
album = {"title": "Hunky Dory", "release_date": date(1971, 12, 17), "plays": 12}
AlbumSchema().dump(album)
# {"title": "Hunky Dory", "release_date": "1971-12-17", "plays": 12}dump() never validates; it only converts. It accepts objects or dicts, reading attributes first and falling back to keys. Use dumps() if you want a JSON string in one step.
Validate input and read the error dictload-and-handle-errors
from marshmallow import Schema, fields, ValidationError
class UserSchema(Schema):
email = fields.Email(required=True)
age = fields.Int(required=True)
try:
UserSchema().load({"email": "nope", "age": "x"})
except ValidationError as err:
print(err.messages)
# {"email": ["Not a valid email address."], "age": ["Not a valid integer."]}
print(err.valid_data) # the fields that did passload() collects every field error before raising, so err.messages is a dict of lists ready to return as a 422 body. err.valid_data holds whatever validated cleanly, which is useful for partial recovery.
Decide what happens to unexpected keysunknown-fields
from marshmallow import Schema, fields, EXCLUDE, INCLUDE, RAISE
class StrictSchema(Schema):
name = fields.Str()
class Meta:
unknown = RAISE # the default
class TolerantSchema(Schema):
name = fields.Str()
class Meta:
unknown = EXCLUDE
# or per call:
TolerantSchema().load(payload, unknown=INCLUDE)RAISE is the default and is the single most common surprise when a client adds a field. EXCLUDE silently drops extras, INCLUDE passes them straight through unvalidated.
Rename wire keys and split read from writerename-and-scope-fields
from marshmallow import Schema, fields
class AccountSchema(Schema):
id = fields.Int(dump_only=True)
email = fields.Email(required=True)
password = fields.Str(load_only=True, required=True)
created_at = fields.DateTime(data_key="createdAt", dump_only=True)data_key controls the external name in both directions. dump_only fields are ignored on load (so clients cannot set an id), load_only fields never appear in output (so passwords do not leak into responses).
Nest schemas and handle listsnested-and-many
from marshmallow import Schema, fields
class ArtistSchema(Schema):
name = fields.Str(required=True)
class AlbumSchema(Schema):
title = fields.Str(required=True)
artist = fields.Nested(ArtistSchema)
tags = fields.List(fields.Str())
# a list of albums
AlbumSchema(many=True).load(payload_list)
# only some nested fields
fields.Nested(ArtistSchema, only=("name",))For self-referencing or circular schemas, pass a callable or the class name as a string ("ArtistSchema"); passing the string "self" was removed. Nested errors nest in messages too, keyed by index for lists.
Turn loaded data into your own classpost-load-object
from dataclasses import dataclass
from marshmallow import Schema, fields, post_load
@dataclass
class User:
name: str
email: str
class UserSchema(Schema):
name = fields.Str(required=True)
email = fields.Email(required=True)
@post_load
def make_user(self, data, **kwargs):
return User(**data)
UserSchema().load({"name": "Ada", "email": "ada@example.com"}) # User(...)Without @post_load you get a dict. Always accept **kwargs: marshmallow passes many, partial and unknown to hook methods, and a fixed signature breaks on upgrade.
Validate single fieldsfield-validators
from marshmallow import Schema, fields, validate, validates, ValidationError
class ProductSchema(Schema):
sku = fields.Str(validate=validate.Regexp(r"^[A-Z]{3}-\d{4}$"))
price = fields.Decimal(validate=validate.Range(min=0), as_string=True)
size = fields.Str(validate=validate.OneOf(["s", "m", "l"]))
@validates("sku")
def check_sku_not_reserved(self, value, data_key):
if value.startswith("ZZZ"):
raise ValidationError("Reserved prefix.")In marshmallow 4 methods decorated with @validates receive data_key as a keyword argument, and custom validators must raise ValidationError; returning False no longer fails validation. @validates can take several field names at once.
Validate across fieldsschema-level-validation
from marshmallow import Schema, fields, validates_schema, ValidationError
class DateRangeSchema(Schema):
start = fields.Date(required=True)
end = fields.Date(required=True)
@validates_schema
def check_order(self, data, **kwargs):
if data["start"] > data["end"]:
raise ValidationError("start must be before end", field_name="start")Schema validators only run when the individual fields passed, so data["start"] is safe to read. Pass field_name to attach the message to a field instead of the _schema key.
Accept PATCH-style partial payloadspartial-updates
# ignore required= for every field
UserSchema().load({"email": "new@example.com"}, partial=True)
# or only for specific fields
UserSchema().load(payload, partial=("name",))
# fields absent from the payload simply do not appear in the resultpartial only relaxes required. Missing keys are omitted from the returned dict rather than set to None, so update code can distinguish "not sent" from "explicitly null" (which needs allow_none=True).
Set defaults for load and dump separatelydefaults-and-none
from marshmallow import Schema, fields
class SettingsSchema(Schema):
theme = fields.Str(load_default="light", dump_default="light")
nickname = fields.Str(allow_none=True, load_default=None)
retries = fields.Int(load_default=3)The old default and missing arguments were removed in 4.0: missing became load_default (used when a key is absent from input) and default became dump_default (used when an attribute is absent on output). allow_none is what permits an explicit null.
Handle enums, datetimes and timedeltasenum-and-datetime
import enum
from marshmallow import Schema, fields
class Status(enum.Enum):
ACTIVE = "active"
BANNED = "banned"
class AccountSchema(Schema):
status = fields.Enum(Status, by_value=True)
created_at = fields.DateTime(format="iso")
ttl = fields.TimeDelta(precision="seconds")by_value=True serializes "active" instead of the member name "ACTIVE". marshmallow 4 parses ISO input with the standard library fromisoformat, so time offsets and bare YYYY-MM-DD datetimes now load where 3.x rejected them, and TimeDelta keeps microseconds instead of truncating.
Give fields access to request contextpass-context
import typing
from marshmallow import Schema, fields
from marshmallow.experimental.context import Context
class Ctx(typing.TypedDict):
current_user_id: int
class PostSchema(Schema):
is_mine = fields.Function(
lambda post: post["author_id"] == Context[Ctx].get()["current_user_id"]
)
with Context[Ctx]({"current_user_id": 42}):
PostSchema().dump({"author_id": 42}) # {"is_mine": True}Schema.context was removed in 4.0. The replacement is a contextvar-backed manager, still marked experimental, so isolate it behind a helper if you would rather not depend on the exact import path.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydantic | PyPI | You want typed model instances, editor autocompletion, and much faster validation, and you accept coupling your data shape to the validation library. |
| msgspec | PyPI | You are decoding large volumes of JSON or MessagePack and want the fastest option, with far fewer validation features. |
| cattrs | PyPI | Your classes are already attrs or dataclasses and you only need structuring and unstructuring, not a separate schema layer. |