Install
$ agentstack add skill-kid-sid-claude-spellbook-pydantic ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Pydantic v2 Patterns
Validation, serialization, and settings management with Pydantic v2.
When to Activate
- Defining request/response schemas or domain models
- Writing
@field_validatoror@model_validatorfor custom validation - Using
Annotatedto build reusable constrained types - Controlling serialization with
model_dump()/model_dump_json() - Building generic models or discriminated unions
- Validating arbitrary data (not a model) with
TypeAdapter - Configuring app settings from environment variables with
pydantic-settings
BaseModel Basics
from pydantic import BaseModel, Field
from datetime import datetime
from uuid import UUID
class User(BaseModel):
id: UUID
name: str
email: str
age: int = Field(ge=0, le=150)
role: str = "user" # default value
created_at: datetime | None = None
# Instantiate
user = User(id="a1b2...", name="Alice", email="alice@example.com", age=30)
# Access
user.name # "Alice"
user.model_fields # dict of FieldInfo
# Validate from dict / JSON
user = User.model_validate({"id": "...", "name": "Alice", ...})
user = User.model_validate_json('{"id": "...", "name": "Alice", ...}')
Field Constraints
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
name: str = Field(min_length=1, max_length=200, strip_whitespace=True)
price: float = Field(gt=0, description="Price in USD")
discount: float = Field(ge=0, le=1, default=0.0) # 0–100%
tags: list[str] = Field(default_factory=list, max_length=10)
sku: str = Field(pattern=r"^[A-Z]{3}-\d{6}$")
metadata: dict = Field(default_factory=dict)
# Alias — accept "product_name" in input, use "name" in Python
name: str = Field(alias="product_name")
Reusable constrained types with Annotated
from typing import Annotated
from pydantic import Field
# Define once, reuse everywhere
PositiveInt = Annotated[int, Field(gt=0)]
Percentage = Annotated[float, Field(ge=0.0, le=1.0)]
NonEmptyStr = Annotated[str, Field(min_length=1, strip_whitespace=True)]
EmailStr = Annotated[str, Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
UserId = Annotated[str, Field(min_length=36, max_length=36)]
class CreateUserRequest(BaseModel):
name: NonEmptyStr
email: EmailStr
age: PositiveInt
discount: Percentage = 0.0
Validators
@field_validator — validate / transform a single field
from pydantic import BaseModel, field_validator
class User(BaseModel):
name: str
email: str
role: str
@field_validator("email")
@classmethod
def lowercase_email(cls, v: str) -> str:
return v.strip().lower()
@field_validator("role")
@classmethod
def valid_role(cls, v: str) -> str:
allowed = {"admin", "user", "viewer"}
if v not in allowed:
raise ValueError(f"role must be one of {allowed}")
return v
# Validate multiple fields at once
@field_validator("name", "email", mode="before") # runs before type coercion
@classmethod
def strip_strings(cls, v: str) -> str:
return v.strip() if isinstance(v, str) else v
mode="before" runs before type coercion. mode="after" (default) runs after.
@model_validator — validate across multiple fields
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: datetime
end_date: datetime
max_days: int = 90
@model_validator(mode="after")
def check_date_range(self) -> "DateRange":
if self.end_date self.max_days:
raise ValueError(f"Range cannot exceed {self.max_days} days")
return self
class PasswordReset(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> "PasswordReset":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
# mode="before" — receives raw dict, before field validation
@model_validator(mode="before")
@classmethod
def handle_legacy_format(cls, data: dict) -> dict:
if "user_name" in data:
data["name"] = data.pop("user_name") # rename legacy field
return data
ConfigDict
from pydantic import BaseModel, ConfigDict
class UserResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True, # allow ORM model → Pydantic (was orm_mode in v1)
populate_by_name=True, # accept both alias and field name
str_strip_whitespace=True, # strip whitespace from all str fields
str_to_lower=False,
extra="forbid", # reject unknown fields (good for request schemas)
# extra="ignore" # silently drop unknown fields
# extra="allow" # keep unknown fields in __pydantic_extra__
frozen=True, # immutable instances (hashable)
arbitrary_types_allowed=True, # allow non-Pydantic types
json_schema_extra={"example": {"name": "Alice", "email": "alice@example.com"}},
)
Serialization
user = User(id=uuid4(), name="Alice", email="alice@example.com", role="admin")
# To dict
user.model_dump()
user.model_dump(exclude={"password", "internal_id"})
user.model_dump(include={"id", "name", "email"})
user.model_dump(exclude_none=True) # omit None values
user.model_dump(exclude_unset=True) # omit fields not explicitly set (useful for PATCH)
user.model_dump(by_alias=True) # use field aliases as keys
user.model_dump(mode="json") # serialize to JSON-compatible types (UUID → str)
# To JSON string
user.model_dump_json()
user.model_dump_json(indent=2, exclude_none=True)
# From ORM (with from_attributes=True)
orm_user = db.query(UserORM).first()
user = UserResponse.model_validate(orm_user)
# Copy with overrides
updated = user.model_copy(update={"role": "admin"})
Discriminated Unions
from pydantic import BaseModel
from typing import Literal, Union, Annotated
from pydantic import Field
class CreditCard(BaseModel):
type: Literal["credit_card"]
number: str
expiry: str
cvv: str
class BankTransfer(BaseModel):
type: Literal["bank_transfer"]
account_number: str
routing_number: str
class Crypto(BaseModel):
type: Literal["crypto"]
wallet_address: str
currency: str
PaymentMethod = Annotated[
Union[CreditCard, BankTransfer, Crypto],
Field(discriminator="type"), # Pydantic uses "type" to pick the right model
]
class Order(BaseModel):
id: str
payment: PaymentMethod
# Pydantic automatically picks the right union member
order = Order.model_validate({
"id": "o-123",
"payment": {"type": "credit_card", "number": "4111...", "expiry": "12/26", "cvv": "123"},
})
isinstance(order.payment, CreditCard) # True
Generic Models
from pydantic import BaseModel
from typing import TypeVar, Generic
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
has_next: bool
class ApiResponse(BaseModel, Generic[T]):
data: T
status: int = 200
message: str = "ok"
# Concrete usage — fully typed
users_page: Page[User] = Page[User](items=[...], total=100, page=1, page_size=20, has_next=True)
response: ApiResponse[User] = ApiResponse[User](data=user)
TypeAdapter — validate without a model
from pydantic import TypeAdapter
# Validate a plain type or complex type
ta = TypeAdapter(list[int])
ta.validate_python([1, 2, "3"]) # [1, 2, 3] — coerces "3" to 3
ta.validate_json("[1, 2, 3]")
# Validate arbitrary dict shape
ta = TypeAdapter(dict[str, list[int]])
ta.validate_python({"a": [1, 2], "b": [3]})
# Great for validating webhook payloads, external API responses
StrippedStr = Annotated[str, Field(strip_whitespace=True, min_length=1)]
ta = TypeAdapter(StrippedStr)
ta.validate_python(" hello ") # "hello"
pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Required — raises if missing from env
database_url: str
secret_key: str
# Optional with defaults
environment: str = "development"
debug: bool = False
redis_url: str = "redis://localhost:6379"
allowed_origins: list[str] = ["http://localhost:3000"]
# Nested prefix: reads TEMPORAL__ADDRESS from env
temporal_address: str = Field("localhost:7233", alias="TEMPORAL_ADDRESS")
@property
def is_production(self) -> bool:
return self.environment == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings() # cached singleton
Env var names match field names case-insensitively. list[str] reads from ALLOWED_ORIGINS=http://a.com,http://b.com (comma-separated).
Red Flags
- Sharing API schemas with the domain layer — using the same Pydantic model as both the HTTP request schema and the internal domain entity couples the API contract to business logic; changes to the API surface silently affect domain behavior and vice versa
- Mutable field defaults without
default_factory—tags: list[str] = []shares the same list object across all instances; usetags: list[str] = Field(default_factory=list)for any mutable default - Not using
model_dump(exclude_unset=True)for PATCH —model_dump()on a partial-update model includes all fields set to their defaults, overwriting database values the client never sent;exclude_unset=Truereturns only the fields the caller explicitly provided orm_mode = True(v1 syntax) in a v2 project — the v1 config key is silently ignored in Pydantic v2; usemodel_config = ConfigDict(from_attributes=True)instead- Catching bare
Exceptionfrommodel_validate— validation errors from Pydantic areValidationError, notValueErrororException; catching the wrong type means bad input crashes the caller with an unhandled exception instead of a structured error response model_dump()when JSON-safe types are needed —model_dump()returns Python objects (UUID, datetime, Decimal) that are not JSON-serializable; usemodel_dump(mode="json")ormodel_dump_json()when the result will be serialized to JSON or stored as a dict in MongoDB- Repeating
Field(gt=0)on every model instead ofAnnotatedtypes — duplicating constraints is error-prone and hard to update; definePositiveInt = Annotated[int, Field(gt=0)]once and reuse it everywhere
Checklist
- [ ]
Annotatedused to define reusable constrained types (not repeating Field() everywhere) - [ ]
@field_validatorwithmode="before"for input normalization (strip, lowercase) - [ ]
@model_validatorfor cross-field validation (date ranges, password confirm) - [ ]
from_attributes=TrueinConfigDictfor ORM → schema conversion - [ ]
extra="forbid"on request schemas to reject unknown input - [ ]
model_dump(exclude_unset=True)for PATCH endpoints (only update what was sent) - [ ]
model_dump(mode="json")when serializing UUIDs/datetimes to dicts - [ ]
TypeAdapterfor validating non-model types (lists, dicts, scalars) - [ ]
pydantic-settingsfor all environment variable config (not rawos.environ) - [ ]
@lru_cacheonget_settings()— load once, reuse
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: kid-sid
- Source: kid-sid/claude-spellbook
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.