Skip to content

Data Validation

Every non-trivial system receives data from sources it does not control: HTTP request bodies parsed From JSON, rows read from CSV files, configuration loaded from environment variables, messages Pulled from a message queue. At the moment data crosses a system boundary, all assumptions you hold About its shape, type, and semantic validity are void. The remote caller may send a string where you Expect an integer, omit a required field entirely, or supply a negative value for a quantity that Must be positive. Without explicit validation at every boundary, corrupt data propagates inward, Corrupting internal state, triggering downstream errors that are difficult to trace back to their Origin, and in the worst case producing silent incorrect results — the most dangerous category of Software failure.

This is the garbage-in-garbage-out principle applied to software architecture: if your program”s Correctness depends on the shape of its inputs, and you do not verify that shape, your program’s Correctness is not guaranteed. Period.

Validation must happen at system boundaries: the outermost layer where external data enters your Application. This includes API endpoints (HTTP request/response bodies, query parameters, path Parameters), configuration loading (environment variables, .env files, YAML/TOML config files), User input (CLI arguments, form submissions), and file parsing (CSV, JSON, XML, protobuf). Once data Has been validated and normalized at a boundary, internal code can operate on it with confidence, Treating it as trusted. The boundary is the choke point: every piece of untrusted data must pass Through it before reaching any business logic.

This raises a critical distinction: validation vs parsing. Parsing transforms raw bytes or Strings into structured Python objects. Validation checks that the resulting objects satisfy Constraints. In practice, these two operations are interleaved — you parse a JSON string into a Dict, then validate that the dict has the right keys with the right types. But conceptually they are Different concerns, and conflating them leads to confusing error messages and missed validation Cases. A robust approach validates during parsing, failing early with precise error messages that Tell the caller exactly what is wrong and where.

Runtime type checking vs static typing is another axis worth clarifying. Static type checkers Like mypy analyze your source code at development time and catch type mismatches in function Signatures and variable assignments. They do not — and cannot — check data that arrives at runtime From external sources. A function annotated as def process(user_id: int) tells mypy that callers Should pass an int, but nothing prevents a JSON payload containing "user_id": "not-a-number" from Reaching that function at runtime. Static typing catches programmer errors; runtime validation Catches data errors. You need both, and they serve different purposes.

Python’s standard library provides only minimal validation primitives: isinstance() checks, int() and float() constructors that raise ValueErrorAnd manual if/else chains. These are Tedious to write, easy to get wrong, and produce poor error messages. The ecosystem has produced Several libraries that address this gap, the most prominent being Pydantic, attrs, and marshmallow.

Pydantic is the dominant data validation library in the Python ecosystem. It is the backbone of FastAPI, used extensively in Django REST Framework integrations, and standard in data engineering Pipelines. Pydantic v2, released in mid-2023, is a ground-up rewrite in Rust (via the pydantic-core Crate) that delivers a 5-50x performance improvement over v1 while maintaining a largely compatible API.

Terminal window
pip install pydantic

The central abstraction is BaseModel. You define a class that inherits from it, declare fields With type annotations, and Pydantic constructs a model that validates and coerces data on Instantiation.

from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
is_active: bool = True
tags: list[str] = []

Instantiation triggers validation. If validation passes, you get back a model instance with all Fields coerced to their declared types. If validation fails, Pydantic raises ValidationError.

user = User(id=1, name="Alice", email="alice@example.com")
print(user.id) # 1
print(user.name) # Alice
print(user.is_active) # True
user2 = User(id="2", name="Bob", email="bob@example.com")
print(user2.id) # 2 (str "2" coerced to int 2)

Pydantic supports the full range of Python type annotations plus its own extensions:

from datetime import datetime, date
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel
class Product(BaseModel):
name: str
price: Decimal
in_stock: bool
quantity: int
rating: float | None = None
created_at: datetime
metadata: dict[str, str] = {}
categories: list[str] = []

The float | None syntax (PEP 604, Python 3.10+) is supported directly. For Python 3.9, use Optional[float] from typing.

Pydantic does not merely check types — it coerces values to the declared type when possible. This is a deliberate design choice that reduces boilerplate in application code at the cost of Silently transforming input. Understanding what coercion does is essential to using Pydantic Correctly.

from pydantic import BaseModel
class Config(BaseModel):
port: int
debug: bool
timeout: float
c = Config(port="8080", debug="yes", timeout="30.5")
print(c.port) # 8080 (str -> int)
print(c.debug) # True (str -> bool, "yes" is truthy)
print(c.timeout) # 30.5 (str -> float)

Coercion rules are extensive. Strings that look like numbers are converted to int or float. Strings that look like booleans ("true"``"false"``"1"``"0"``"yes"``"no") are converted To bool. Strings that look like ISO 8601 dates/datetimes are converted to date/datetime Objects. Lists and dicts are recursively validated. The full coercion logic lives in pydantic-core And is well-documented in the Pydantic docs.

When validation fails, Pydantic raises ValidationErrorWhich carries structured information about Every problem found in the input.

from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
name: str
email: str
age: int
try:
User(id="not_a_number", name="", email="bad")
except ValidationError as e:
print(e.error_count()) # 3
for error in e.errors():
print(error)

Each error in e.errors() is a dict with these keys:

  • type — a machine-readable error code (e.g., "int_parsing"``"string_too_short"``"missing")
  • loc — a tuple indicating the path to the offending field (e.g., ('id',) ('address', 'city'))
  • msg — a human-readable error message
  • input — the actual value that failed validation

This structured error format is designed for programmatic consumption — you can serialize it to JSON and return it directly in an API error response.

Field() is the mechanism for attaching validation constraints beyond what the type annotation Expresses. A plain int annotation tells Pydantic the value must be an integer; Field(gt=0) tells It the integer must be strictly greater than zero.

from pydantic import BaseModel, Field
class Sensor(BaseModel):
temperature: float = Field(ge=-40.0, le=85.0)
humidity: float = Field(ge=0.0, le=100.0)
pressure: float = Field(gt=0.0)
sample_rate: int = Field(ge=1, le=1000)

The constraint parameters for numeric types are:

ParameterMeaning
gtstrictly greater than
gegreater than or equal
ltstrictly less than
leless than or equal
multiple_ofmust be a multiple of this value
from pydantic import BaseModel, Field
class Account(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_]+$")
bio: str = Field(max_length=500, default="")
email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")

The constraint parameters for str are:

ParameterMeaning
min_lengthminimum string length
max_lengthmaximum string length
patternregex pattern the string must match

Note: pattern uses Python’s re module. The regex is matched against the entire string (anchored At both ends), so you do not need ^ and $ anchors, though including them is harmless.

from pydantic import BaseModel, Field
class Job(BaseModel):
name: str
priority: int = Field(default=0)
tags: list[str] = Field(default_factory=list)
config: dict[str, str] = Field(default_factory=dict)

The distinction between default and default_factory is the same as in dataclasses: default Accepts an immutable value, while default_factory accepts a zero-argument callable that produces a New value each time. Using default=[] is a classic Python bug — the same list object would be Shared across all instances that don’t provide their own value. default_factory=list creates a Fresh list every time.

Field() also carries metadata that does not affect validation but is used for documentation, Schema generation, and serialization:

from pydantic import BaseModel, Field
class Server(BaseModel):
host: str = Field(description="Server hostname or IP address")
port: int = Field(default=8080, ge=1, le=65535, description="Listening port")
api_key: str = Field(
alias="apiKey",
description="API authentication key",
deprecated="Use token-based auth instead",
examples=["sk-abc123", "sk-xyz789"],
)
  • description — used in JSON Schema generation and API documentation
  • alias — alternative key name during serialization/deserialization
  • deprecated — marks the field as deprecated, surfaced in schema generation
  • examples — sample values for documentation

Field constraints cover simple cases: ranges, lengths, patterns. For anything more complex — cross- Field validation, conditional logic, custom transformations — you need validators.

@field_validator decorates a method that validates a single field. The method receives the field’s Value and returns the (possibly transformed) value, or raises ValueError or ValidationError.

from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
password: str
password_confirm: str
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("Password must contain an uppercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain a digit")
return v
@field_validator("password_confirm")
@classmethod
def passwords_match(cls, v: str, info) -> str:
if "password" in info.data and v != info.data["password"]:
raise ValueError("Passwords do not match")
return v

The info argument is a ValidationInfo object. Its data attribute contains a dict of fields That have already been validated (in declaration order). This enables cross-field validation: Checking that password_confirm equals passwordThat end_date is after start_dateEtc.

Note that info.data only contains fields declared before the current field. If you need to Validate a field against one declared after it, use a model validator instead.

from pydantic import BaseModel, field_validator
class Measurement(BaseModel):
value: str
@field_validator("value", mode="before")
@classmethod
def preprocess(cls, v):
if isinstance(v, (int, float)):
return str(v)
return v
@field_validator("value", mode="after")
@classmethod
def validate_format(cls, v: str) -> str:
if not v.replace(".", "", 1).isdigit():
raise ValueError("Must be a numeric string")
return v
  • mode="before" — the validator runs before Pydantic’s built-in type coercion. The raw input value is passed in. This is where you preprocess or normalize input.
  • mode="after" — the validator runs after type coercion. You receive a value that is already the declared type. This is where you apply business rules.
  • mode="wrap" — the validator receives both the value and a handler function. It can run custom logic before, after, or around the default validation:
from pydantic import BaseModel, field_validator
class Document(BaseModel):
content: str
@field_validator("content", mode="wrap")
@classmethod
def sanitize_and_validate(cls, v, handler):
if isinstance(v, str):
v = v.strip()
result = handler(v)
if len(result) == 0:
raise ValueError("Content cannot be empty after stripping")
return result

@field_validator("*") applies to every field in the model. This is useful for transformations that Must apply uniformly, such as stripping whitespace from all string fields.

from pydantic import BaseModel, field_validator
class Form(BaseModel):
first_name: str
last_name: str
city: str
@field_validator("*")
@classmethod
def strip_strings(cls, v):
if isinstance(v, str):
return v.strip()
return v

@model_validator operates on the entire model, after all individual fields have been validated. Use it for constraints that involve multiple fields where the order of declaration matters.

from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start: str
end: str
@model_validator(mode="after")
def end_after_start(self):
if self.start >= self.end:
raise ValueError("end must be after start")
return self

mode="before" receives the raw input ( a dict) before any field validation runs. This Lets you preprocess the entire input structure:

from pydantic import BaseModel, model_validator
class Event(BaseModel):
timestamp: str
message: str
@model_validator(mode="before")
@classmethod
def normalize_keys(cls, values):
if isinstance(values, dict):
normalized = {}
for k, v in values.items():
normalized[k.replace("-", "_")] = v
return normalized
return values

Real-world data is hierarchical. A user has an address, which has a city and postal code. An order Has a list of line items. A configuration has nested sections. Pydantic handles all of these by using models as field types.

from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
postal_code: str
class Company(BaseModel):
name: str
headquarters: Address
branches: list[Address]

Instantiation works with nested dicts:

company = Company(
name="Acme Corp",
headquarters={
"street": "123 Main St",
"city": "Springfield",
"postal_code": "62701",
},
branches=[
{"street": "456 Oak Ave", "city": "Shelbyville", "postal_code": "62565"},
],
)

Validation errors propagate with full path information. If the postal code in the first branch is Invalid, the error’s loc will be ('branches', 0, 'postal_code')Telling you exactly where the Problem is.

from pydantic import BaseModel
class Permission(BaseModel):
read: bool = False
write: bool = False
admin: bool = False
class Role(BaseModel):
name: str
permissions: dict[str, Permission]
role = Role(
name="editor",
permissions={
"documents": {"read": True, "write": True},
"settings": {"read": True},
},
)

Models that reference themselves are supported, but you must use model_rebuild() to resolve the Forward reference after the class is defined:

from pydantic import BaseModel, ConfigDict
class Node(BaseModel):
model_config = ConfigDict(from_attributes=True)
value: int
children: list["Node"] = []
Node.model_rebuild()
tree = Node(
value=1,
children=[
Node(value=2, children=[Node(value=4), Node(value=5)]),
Node(value=3),
],
)

from_attributes=True enables construction from objects with matching attributes (e.g., ORM model Instances), not just dicts. This is the v2 replacement for v1’s orm_mode.

print(tree.model_dump_json(indent=2))

This recursively serializes the entire tree to a JSON string. Each nested Node is serialized According to its own model definition. The indent parameter produces pretty-printed output, which Is useful for debugging but should not be used in production (larger payloads, slower Serialization).

Serialization is the inverse of validation: converting a validated model instance back into a plain Dict or JSON string. Pydantic v2 provides a unified serialization API that is separate from Validation, with its own set of options.

from pydantic import BaseModel
from datetime import datetime
class Event(BaseModel):
name: str
timestamp: datetime
count: int
event = Event(name="deploy", timestamp=datetime(2026, 4, 5, 12, 0, 0), count=3)
event.model_dump()
## {'name': "deploy'', "timestamp': datetime.datetime(2026, 4, 5, 12, 0), 'count': 3}
event.model_dump(mode="json")
## {'name': "deploy'', "timestamp': "2026-04-05T12:00:00'', "count': 3}
event.model_dump_json()
# '{"name":"deploy","timestamp":"2026-04-05T12:00:00","count":3}'

The mode="json" argument tells Pydantic to serialize all values to JSON-compatible types. datetime objects become ISO 8601 strings, Decimal objects become floats, UUID objects become Strings, etc. Use this mode when you need to pass the result to json.dumps() or send it over the Wire.

model_dump_json() is equivalent to json.dumps(model.model_dump(mode="json")) but implemented in Rust, so it is significantly faster.

event.model_dump(include={"name", "timestamp"})
# {'name': "deploy'', "timestamp': datetime(2026, 4, 5, 12, 0)}
event.model_dump(exclude={"timestamp"})
# {'name': "deploy'', "count': 3}
event.model_dump(exclude_defaults=True)
# fields with default values that were not explicitly set are omitted
event.model_dump(exclude_unset=True)
# only fields that were explicitly passed to the constructor are included

exclude_defaults and exclude_unset have subtly different semantics. exclude_defaults omits any Field whose value equals its default (whether or not the caller provided it). exclude_unset omits Only fields that the caller did not provide (even if the default was applied). This distinction Matters when a caller explicitly passes a value that happens to equal the default.

from pydantic import BaseModel, field_serializer, AwareDatetime
from datetime import datetime, timezone
class LogEntry(BaseModel):
timestamp: datetime
level: str
message: str
@field_serializer("timestamp")
def serialize_timestamp(self, dt: datetime, _info):
return dt.strftime("%Y-%m-%d %H:%M:%S %Z")

@model_serializer controls the entire serialization of a model:

from pydantic import BaseModel, model_serializer
class Envelope(BaseModel):
status: str
data: dict
@model_serializer(mode="wrap")
def serialize_model(self, handler):
result = handler(self)
return {"response": result, "version": "1.0"}

The pattern of model.model_validate(data) -> model.model_dump() -> model.model_validate(dumped) should be lossless for most practical purposes, but there are edge Cases. Decimal values may lose precision when serialized to JSON (since JSON has no native decimal Type). datetime objects lose timezone information if not careful. Custom types with complex Internal state may not survive the round-trip. Always test your round-trip behavior.

Pydantic provides two surface APIs: BaseModel and @pydantic.dataclass. The latter wraps Python’s Stdlib dataclass with Pydantic’s validation and serialization machinery.

import pydantic
@pydantic.dataclass
class Point:
x: float
y: float
z: float = 0.0
p = Point(x=1.0, y=2.0)
print(p.x) # 1.0
FeatureBaseModel@pydantic.dataclass
Inheritance from BaseModelYesNo (inherits from dataclass)
model_dump() / model_dump_json()YesYes
model_validate()YesYes
model_json_schema()YesYes
model_configYesVia Config or kw_only
Composability with other dataclassesNoYes (dataclass interop)
Standard dataclasses.is_dataclass()NoYes
Extra fields behaviorConfigurableConfigurable

Use BaseModel for any model that represents a structured data contract — API request/response Schemas, configuration objects, domain entities. BaseModel is the default choice and has the most Complete feature set.

Use @pydantic.dataclass when you need to interoperate with code that expects standard dataclasses (e.g., dataclasses.asdict()``dataclasses.fields()), when you want value-based equality and Hashing without implementing __eq__ and __hash__Or when you are working within a codebase that Already uses dataclasses extensively and you want to add validation without changing the overall Architecture.

Do not mix them casually. A BaseModel instance is not a dataclass and vice versa. Pick one pattern Per module and stick with it.

Pydantic can generate JSON Schema (Draft 2020-12) from any model. This is not an academic feature — It is the foundation for API documentation in frameworks like FastAPI and for validating data from External systems that consume your schema.

from pydantic import BaseModel, Field
class User(BaseModel):
id: int = Field(description="Unique user identifier")
name: str = Field(min_length=1, max_length=100)
email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")
role: str = Field(default="viewer", pattern=r"^(admin|editor|viewer)$")
schema = User.model_json_schema()
import json
print(json.dumps(schema, indent=2))

The output is a complete JSON Schema document that encodes all type information, constraints, Defaults, descriptions, and examples. Field constraints like min_length``patternAnd ge/le Are translated to their JSON Schema equivalents (minLength``pattern``minimum/maximum).

FastAPI uses model_json_schema() internally to generate OpenAPI documentation. When you declare a Pydantic model as a request body or response model, FastAPI extracts the schema and includes it in The auto-generated /docs and /openapi.json endpoints. This is why FastAPI’s interactive Documentation is so detailed — it is driven entirely by the type annotations and field constraints In your Pydantic models.

import jsonschema
schema = User.model_json_schema()
jsonschema.validate(
instance={"id": 1, "name": "Alice", "email": "alice@example.com"},
schema=schema,
)

This is useful when you need to validate data in environments that cannot run Pydantic (e.g., in a JavaScript frontend, in a message broker’s schema registry). The JSON Schema is a language-agnostic Contract; Pydantic models are the Python implementation of that contract.

Nested models produce nested schema definitions. Recursive models use $ref to avoid infinite Recursion:

class Node(BaseModel):
value: int
children: list["Node"] = []
Node.model_rebuild()
schema = Node.model_json_schema()

The schema will define Node once and reference it via $ref: "#/$defs/Node" wherever it appears Recursively. This is standard JSON Schema practice and is understood by all compliant validators.

Configuration management is a specialized form of data validation where the data source is the Environment (environment variables, .env files, CLI arguments) rather than a request body or file. Pydantic provides BaseSettings for exactly this use case.

from pydantic_settings import BaseSettings
class AppConfig(BaseSettings):
database_url: str
redis_url: str = "redis://localhost:6379"
debug: bool = False
max_connections: int = 10
model_config = SettingsConfigDict(env_file=".env")

pip install pydantic-settings is required — this is a separate package from pydantic itself.

BaseSettings reads values from environment variables first, then falls back to .env file values, Then falls back to field defaults. This layering is the standard 12-factor app approach to Configuration.

By default, Pydantic maps environment variables to fields by uppercasing the field name. The field database_url maps to the environment variable DATABASE_URL.

class AppConfig(BaseSettings):
database_url: str
model_config = SettingsConfigDict(env_prefix="APP_")

With env_prefix="APP_"The field database_url maps to APP_DATABASE_URL.

For nested models, env_nested_delimiter allows flattening hierarchy into the environment:

from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "mydb"
class AppConfig(BaseSettings):
database: DatabaseConfig
model_config = SettingsConfigDict(env_nested_delimiter="__")
# Environment: DATABASE__HOST=db.example.com DATABASE__PORT=5433
config = AppConfig()
print(config.database.host) # db.example.com
print(config.database.port) # 5433

All of Pydantic’s validation machinery works inside BaseSettings. If you declare port: int = Field(ge=1, le=65535)Setting PORT=99999 will raise ValidationError at import Time, failing fast before your application starts. This is exactly what you want — configuration Errors should be caught at startup, not at 3 AM when a request finally hits the invalid code path.

from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class AppConfig(BaseSettings):
port: int = Field(default=8080, ge=1, le=65535)
workers: int = Field(default=4, ge=1)
timeout: float = Field(default=30.0, gt=0)
model_config = SettingsConfigDict(env_file=".env")

Attrs is a data class library that predates Pydantic and takes a fundamentally different approach. It focuses on reducing the boilerplate of writing classes (automatically generating __init__ __repr__``__eq__``__hash__) with optional validation via composable validator functions.

Terminal window
pip install attrs
import attr
@attr.define
class Server:
host: str
port: int
workers: int = 4
@port.validator
def _validate_port(self, attribute, value):
if not 1 <= value <= 65535:
raise ValueError(f"Port must be 1-65535, got {value}")

@attr.define generates a class with __init__``__repr__``__eq__And __hash__. The @attribute.validator decorator attaches a validation function that runs when the attribute is set.

@attr.frozen
class Coordinate:
x: float
y: float

@attr.frozen produces immutable instances. Attempting to set an attribute after construction Raises attr.exceptions.FrozenInstanceError. This is equivalent to frozen=True in stdlib Dataclasses.

ConcernPydanticattrs
Primary purposeData validation and parsingBoilerplate reduction
Type coercionAutomaticNone
Validation errorsStructured ValidationErrorRaw ValueError
JSON serializationBuilt-inRequires cattrs
JSON Schema generationBuilt-inNot available
Settings managementBuilt-in (BaseSettings)Not available
Runtime overheadHigher (Rust validation core)Minimal
Validation granularityField-level, model-levelAttribute-level
Ecosystem integrationFastAPI, Django, etc.Independent

Use attrs when you need a simple, low-boilerplate class definition without the overhead of Pydantic’s validation machinery. Attrs is ideal for internal domain objects where the data is Already trusted — e.g., objects constructed programmatically within your application, not from External input. If you are defining a configuration schema, an API contract, or anything that Touches untrusted data, use Pydantic.

Attrs has near-zero runtime overhead compared to Pydantic because it does not perform type coercion Or complex validation by default. The validator functions you attach are plain Python callables that Run only when you set them up. For performance-sensitive inner-loop code, this matters.

Marshmallow is an object serialization/deserialization library with a different philosophy from Pydantic. It explicitly separates parsing (deserialization: external data -> Python objects) From validation (checking constraints). This separation is a feature, not a bug — it allows you To define different validation rules for input vs output, and it makes the parsing and validation Steps independently testable.

Terminal window
pip install marshmallow
from marshmallow import Schema, fields, ValidationError
class UserSchema(Schema):
id = fields.Integer(required=True)
name = fields.String(required=True, validate=lambda s: len(s) >= 1)
email = fields.Email(required=True)
created_at = fields.DateTime(dump_only=True)
try:
data = UserSchema().load({"id": 1, "name": "Alice", "email": "alice@example.com"})
print(data) # {'id': 1, 'name': "Alice'', "email': "alice@example.com''}
except ValidationError as e:
print(e.messages)

load() deserializes and validates. dump() serializes. The schema definition is separate from the Python class — you can have multiple schemas for the same class (e.g., UserCreateSchema UserUpdateSchema``UserPublicSchema).

class AddressSchema(Schema):
street = fields.String(required=True)
city = fields.String(required=True)
class UserSchema(Schema):
name = fields.String(required=True)
address = fields.Nested(AddressSchema, required=True)
from marshmallow import Schema, fields, pre_load, post_load
class UserSchema(Schema):
username = fields.String(required=True)
email = fields.Email(required=True)
@pre_load
def normalize_keys(self, data, **kwargs):
if "userName" in data:
data["username"] = data.pop("userName")
return data
@post_load
def make_user(self, data, **kwargs):
return User(**data)

pre_load runs before validation and can preprocess the input dict. post_load runs after Validation and can construct a domain object from the validated dict. This is where the explicit Separation of parsing and validation becomes powerful.

Use marshmallow when you need explicit control over the parsing/validation/serialization pipeline, When you have multiple schemas for the same model (input, output, partial update), or when you are Working within a framework that integrates with marshmallow natively (e.g., Flask-Marshmallow, Django-Marshmallow, webargs for argument parsing).

Marshmallow does not perform type coercion the way Pydantic does. If you pass "42" where an Integer is expected, marshmallow raises a validation error rather than silently converting it. This Is a more conservative approach that makes data transformations explicit. Whether this is an Advantage or disadvantage depends on your use case.

from pydantic import BaseModel
class Bad(BaseModel):
items: list = []
class Good(BaseModel):
items: list = []

In Pydantic v2, items: list = [] is actually safe because Pydantic copies the default on each Instantiation. However, this is a subtle behavioral difference from stdlib dataclasses, and relying On it makes your code fragile if you ever migrate away from Pydantic. Always use Field(default_factory=list) to make the intent explicit and portable.

Validators run in field declaration order. If field b depends on field a``a must be declared First. Otherwise, info.data in b”s validator will not contain a. If you cannot reorder fields (for whatever reason), use @model_validator(mode="after") instead.

Pydantic coerces by default. user = User(id="42") works because "42" is coerced to 42. If you Do not want coercion — if "42" should be a validation error because the caller sent a string — Use strict mode:

from pydantic import BaseModel, ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(strict=True)
id: int
name: str
StrictUser(id=42, name="Alice") # OK
StrictUser(id="42", name="Alice") # ValidationError

Always consider whether coercion is appropriate for your use case. For API endpoints, coercion is desirable (HTTP form data is always strings). For internal data structures, strict mode may Be safer.

By default, Pydantic v2 ignores extra fields not declared in the model:

class User(BaseModel):
name: str
u = User(name="Alice", age=30) # age is silently ignored

This behavior is configurable:

class StrictUser(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
StrictUser(name="Alice", age=30) # ValidationError: Extra inputs are not permitted

Use extra="forbid" in API schemas to catch typos and protocol mismatches early. A caller sending user_name instead of name with extra="ignore" would silently lose the data. With extra="forbid"The error is immediate and clear.

BaseSettings Must Import from pydantic-settings

Section titled “BaseSettings Must Import from pydantic-settings”
from pydantic_settings import BaseSettings
class Config(BaseSettings):
pass

BaseSettings is not in the pydantic package in v2. It was moved to pydantic-settings. This Is a common source of ImportError when migrating from v1.

Validation Runs at Construction Time, Not Assignment Time

Section titled “Validation Runs at Construction Time, Not Assignment Time”
from pydantic import BaseModel
class User(BaseModel):
age: int = Field(ge=0)
u = User(age=25)
u.age = -5 # No ValidationError!

In v2, assignment to model attributes bypasses validation by default. If you need validation on Assignment, use model_config = ConfigDict(validate_assignment=True). Be aware that this has a Performance cost — every assignment triggers the full validation pipeline for that field.

class Node(BaseModel):
children: list["Node"] = []
Node.model_rebuild() # REQUIRED

Without model_rebuild()Pydantic will raise SchemaError or produce incorrect behavior because The forward reference "Node" has not been resolved. Always call model_rebuild() after defining Recursive models.

Decimal fields are serialized as number in JSON Schema, which means JSON consumers may parse Them as floating-point values, losing precision. If exact decimal precision matters (financial Calculations, scientific measurements), consider serializing decimals as strings and documenting the Expected format.

marshmallow Schemas Are Not Type-Annotated

Section titled “marshmallow Schemas Are Not Type-Annotated”

Marshmallow’s Schema class uses instance attributes (fields.String()``fields.Integer()) rather Than Python type annotations. This means mypy cannot infer the shape of schema.load(data) — it Will type it as dict[str, Any]. Pydantic’s type-annotated models integrate with mypy out of the Box, giving you static type checking for free. If type safety is a priority, this is a significant Advantage for Pydantic.

Do not use Pydantic, attrs, and marshmallow in the same module. Each library has its own validation Semantics, error types, and serialization conventions. Mixing them creates confusion about which Validation is in effect and makes the codebase harder to maintain. Pick one library as your primary Validation tool and use it consistently. If you must interoperate (e.g., using attrs for domain Objects and Pydantic for API schemas), keep the boundary between them explicit and minimal.

This topic covers the core concepts of data validation, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • relational databases and SQL
  • normalisation (1NF, 2NF, 3NF)
  • entity-relationship diagrams
  • transaction processing (ACID)
  • NoSQL and distributed databases

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Data validation is the bouncer at the door of your application, checking every piece of data that tries to enter. Without it, you are letting strangers walk into your house and rearrange your furniture. The garbage-in-garbage-out principle means that if you feed a program bad data, it will produce bad results with perfect confidence. Pydantic models are like molds: pour data in, and if it does not fit the shape, it gets rejected immediately with a clear error message rather than silently corrupting your system three layers deeper.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.


  • Essential Modules provides the standard library tools like and used in data validation pipelines.
  • File I/O covers reading and writing validated data to persistent storage formats.
  • CLI Tools shows how to build command-line interfaces that accept and validate user input.