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.

  • Languages
  • $pydantic-v2
  • 217 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pydantic-v2

Installs to ~/.agents/skills/pydantic-v2/ for every project on this machine.

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.

~/.agents/skills/pydantic-v2/SKILL.md

--- name: pydantic-v2 description: Builds and migrates Pydantic v2 models - ConfigDict, Annotated constraints, field and model validators, discriminated unions, TypeAdapter, custom… license: MIT ---

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.md for 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 keyUse when
extra="forbid"Request bodies and config files: typos become errors
extra="ignore" (default)Consuming third-party payloads that grow fields
frozen=TrueValue objects; also makes instances hashable
strict=TrueNo coercion at all (“1” is not an int). Use per field with Field(strict=True) for IDs and money
from_attributes=TrueBuilding models from ORM objects (model_validate(orm_obj))
validate_assignment=TrueMutable 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

NeedTool
Range, length, regexField(...) constraints inside Annotated
Reusable transform on a typeAnnotated[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 (or PydanticCustomError for a custom error type/code). Do not raise ValidationError yourself and never use assert (stripped under python -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-settings is a separate package (install needs network/approval in a sandbox).
  • Precedence: init kwargs > environment variables > .env file > defaults.
  • SecretStr hides 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
v1v2
.dict(), .json().model_dump(), .model_dump_json()
parse_obj, parse_raw, from_ormmodel_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_namefrom_attributes, populate_by_name
Field(regex=...), min_items, const=Truepattern=, min_length, Literal[...]
__fields__, update_forward_refs(), .copy()model_fields, model_rebuild(), .model_copy()
Optional[int] implied = Noneint | None is required unless you write = None
each_item=True validatorsValidate 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" becomes 42, "true" becomes True, and an int field accepts 4.0 but rejects 4.5. Use strict where coercion would hide client bugs.
  • str | None without a default is required; clients must send null explicitly.
  • Mutable defaults (tags: list[str] = []) are safe in Pydantic (copied per instance), unlike dataclasses. Do not “fix” them with default_factory unless the factory computes something.
  • Field names starting with model_ collide with Pydantic’s namespace and warn.
  • In StringConstraints, pattern is checked before to_lower is applied, so a lowercase-only regex rejects "Hello". Make the pattern accept both cases, or lowercase in a BeforeValidator.
  • 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 loc and type: 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::DeprecationWarning where practical).

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem