Pydantic v2 skill for Codex
Validate once at the edge, trust it everywhere. Codex writes Pydantic models with constraints in Annotated types, picks the narrowest validator for each rule, and uses discriminated unions so errors point at the right branch. It loads settings through pydantic-settings with secrets masked, and migrates v1 code with bump-pydantic plus a mapping table, catching the Optional-is-now-required change that breaks clients.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pydantic-v2 Installs to ~/.agents/skills/pydantic-v2/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pydantic-v2 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pydantic-v2 Installs to .agents/skills/pydantic-v2/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pydantic-v2.zipunzip -o pydantic-v2.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pydantic-v2 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $pydantic-v2, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and migrates Pydantic v2 models - ConfigDict, Annotated constraints, field and model validators, discriminated unions, TypeAdapter, custom serialization, pydantic-settings for environment config, and the v1 to v2 API migration with bump-pydantic. Use when a user defines request or config schemas in Pydantic, hits a ValidationError they do not understand, upgrades from Pydantic v1, or asks for settings loaded from env vars and .env files.
Use it when
- You are defining request, response or config schemas in Python
- A codebase still uses .dict(), @validator or class Config
- ValidationError output is confusing or validation is slow
- Settings need typed loading from environment variables
Use something else when
- You only need static type hints with no runtime validation: use python-typing
- The question is FastAPI routing and dependency injection: use fastapi-services
How to check the result
Each validator has a passing and a failing test asserting the error location, and a grep for v1 APIs in migrated modules comes back empty.
The mistake it prevents
Assuming Optional fields default to None as in v1; in v2 they stay required until you write = None.
Done well means: models validate at the boundary and nowhere else, constraints live in
types rather than in hand-written if chains, serialization is explicit (mode="json"),
and no v1-era API (.dict(), @validator, class Config) survives a migration.
Before changing anything
- Check the installed version:
python -c "import pydantic; print(pydantic.VERSION)". Advice below targets 2.7+; features added later are marked. - Grep for v1 usage to size a migration:
rg -n "\.dict\(|\.json\(|parse_obj|@validator|@root_validator|class Config:|from pydantic\.v1|orm_mode" src - Read
AGENTS.mdfor where schemas live and whether FastAPI or SQLModel is involved; both pin compatible Pydantic ranges.
Model defaults
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
PositiveCents = Annotated[int, Field(ge=0, description="Amount in minor units")]
class LineItem(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
sku: Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[A-Z0-9-]+$")]
quantity: Annotated[int, Field(gt=0, le=10_000)]
unit_price: PositiveCents
class Order(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
customer_email: EmailStr # needs the email-validator extra
status: Literal["draft", "paid", "shipped"] = "draft"
items: list[LineItem] = Field(min_length=1)
created_at: datetime
note: str | None = None # optional AND defaulted; see gotchas
| Config key | Use when |
|---|---|
extra="forbid" | Request bodies and config files: typos become errors |
extra="ignore" (default) | Consuming third-party payloads that grow fields |
frozen=True | Value objects; also makes instances hashable |
strict=True | No coercion at all (“1” is not an int). Use per field with Field(strict=True) for IDs and money |
from_attributes=True | Building models from ORM objects (model_validate(orm_obj)) |
validate_assignment=True | Mutable models whose fields change after construction |
populate_by_name=True (2.11+: validate_by_name=True) | Accept both alias and field name |
Validators: pick the narrowest tool
| Need | Tool |
|---|---|
| Range, length, regex | Field(...) constraints inside Annotated |
| Reusable transform on a type | Annotated[str, AfterValidator(fn)] / BeforeValidator(fn) |
| One field, needs custom logic | @field_validator("name") |
| Cross-field rule | @model_validator(mode="after") |
| Reshape raw input before parsing | @model_validator(mode="before") |
from typing import Self
from pydantic import AfterValidator, StringConstraints, field_validator, model_validator
RESERVED = {"admin", "api", "login"}
def _not_reserved(v: str) -> str:
if v in RESERVED:
raise ValueError(f"'{v}' is reserved")
return v
Slug = Annotated[
str,
StringConstraints(strip_whitespace=True, to_lower=True, pattern=r"^[A-Za-z0-9-]+$"),
AfterValidator(_not_reserved),
]
class Booking(BaseModel):
slug: Slug
start: datetime
end: datetime
tags: list[str] = []
@field_validator("tags", mode="after")
@classmethod
def dedupe(cls, v: list[str]) -> list[str]:
return sorted(set(v))
@model_validator(mode="after")
def check_range(self) -> Self:
if self.end <= self.start:
raise ValueError("end must be after start")
return self
- Raise
ValueError(orPydanticCustomErrorfor a custom error type/code). Do not raiseValidationErroryourself and never useassert(stripped underpython -O). mode="after"validators receive parsed, typed values; prefer them.mode="before"receives raw input of any type and must handle that.- An after model validator must return
self.
Discriminated unions
class Card(BaseModel):
kind: Literal["card"]
last4: str
class Bank(BaseModel):
kind: Literal["bank"]
iban: str
class Payment(BaseModel):
method: Annotated[Card | Bank, Field(discriminator="kind")]
Without the discriminator, Pydantic tries each member (“smart” mode) and error messages list failures for every branch. With it, validation is faster and errors name the right model.
Validating things that are not models
from pydantic import TypeAdapter
ItemsAdapter = TypeAdapter(list[LineItem]) # build once at module level; construction is costly
items = ItemsAdapter.validate_json(raw_bytes) # faster than json.loads + validate_python
schema = ItemsAdapter.json_schema()
Serialization
order.model_dump() # Python objects (datetime stays datetime)
order.model_dump(mode="json") # JSON-safe primitives
order.model_dump_json(exclude_none=True, by_alias=True)
order.model_copy(update={"status": "paid"}) # no validation of the update; re-validate if needed
Custom output with @field_serializer("amount") or a computed value with @computed_field
on a @property (included in dumps and JSON schema).
Settings from the environment
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_", env_file=".env", env_nested_delimiter="__", extra="ignore"
)
database_url: str
api_key: SecretStr
debug: bool = False
pool_size: int = 10
settings = Settings() # APP_DATABASE_URL, APP_API_KEY, APP_DB__... for nested models
pydantic-settingsis a separate package (install needs network/approval in a sandbox).- Precedence: init kwargs > environment variables >
.envfile > defaults. SecretStrhides values in repr and logs; call.get_secret_value()only where needed.- Instantiate once (module-level or
functools.cache) and fail at startup, not at first use.
Migrating from v1
uvx bump-pydantic src/ # needs network; rewrites most mechanical changes, review the diff
| v1 | v2 |
|---|---|
.dict(), .json() | .model_dump(), .model_dump_json() |
parse_obj, parse_raw, from_orm | model_validate, model_validate_json, model_validate + from_attributes=True |
class Config: | model_config = ConfigDict(...) |
@validator, @root_validator | @field_validator (+ @classmethod), @model_validator |
orm_mode, allow_population_by_field_name | from_attributes, populate_by_name |
Field(regex=...), min_items, const=True | pattern=, min_length, Literal[...] |
__fields__, update_forward_refs(), .copy() | model_fields, model_rebuild(), .model_copy() |
Optional[int] implied = None | int | None is required unless you write = None |
each_item=True validators | Validate the item type with Annotated[...] instead |
Staged path for big codebases: from pydantic import v1 as pydantic_v1 keeps untouched modules
on the v1 API inside v2 while you migrate module by module. Never mix v1 and v2 models in one
nested structure.
Gotchas
- Lax mode coerces:
"42"becomes42,"true"becomesTrue, and anintfield accepts4.0but rejects4.5. Usestrictwhere coercion would hide client bugs. str | Nonewithout a default is required; clients must sendnullexplicitly.- Mutable defaults (
tags: list[str] = []) are safe in Pydantic (copied per instance), unlike dataclasses. Do not “fix” them withdefault_factoryunless the factory computes something. - Field names starting with
model_collide with Pydantic’s namespace and warn. - In
StringConstraints,patternis checked beforeto_loweris applied, so a lowercase-only regex rejects"Hello". Make the pattern accept both cases, or lowercase in aBeforeValidator. model_construct()skips validation entirely; use only for data you already validated.- Validation errors include input values; scrub
e.errors(include_input=False)before logging user payloads that may hold secrets.
Verification
- Tests cover one valid payload and one invalid payload per validator, asserting the error
locandtype:exc.value.errors()[0]["loc"] == ("items", 0, "quantity"). Model.model_json_schema()matches what API consumers expect (diff it against the published OpenAPI schema when FastAPI is involved).- After a migration:
rg "\.dict\(|@validator|class Config:"returns nothing in migrated modules, and the full test suite passes with warnings enabled (pytest -W error::DeprecationWarningwhere practical).
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Pydantic v2"
short_description: "Validate once at the edge, trust it everywhere"
default_prompt: "$pydantic-v2 Migrate our models from Pydantic v1 to v2 without breaking the API" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.