TechWayFit
● Complete Learning Series Python & AI Engineering for .NET Developers

Structured Data Validation with Pydantic v2

Tech Buddy July 13, 2026 3 min read
Structured Data Validation with Pydantic v2

Pydantic is to Python what data annotations + FluentValidation are to .NET — except it's faster (Rust-backed core in v2), more expressive, and has become the de facto standard for AI application data modeling. The OpenAI SDK, LangChain, FastAPI, and most enterprise AI stacks use Pydantic for schema definition and validation. Learning it is non-negotiable.

BaseModel: The Foundation

Every Pydantic model inherits from BaseModel. Fields are defined as class-level type annotations. Pydantic validates on instantiation and coerces compatible types automatically.

C# Data Annotations
													public class ChatRequest
                      {
                          [Required]
                          public string Model { get; set; }
                      
                          [Range(1, 8192)]
                          public int MaxTokens { get; set; } = 1024;
                      
                          [Required, MinLength(1)]
                          public List<Message> Messages { get; set; }
                      
                          [Range(0.0, 1.0)]
                          public float Temperature { get; set; } = 0.7f;
                      }
                      												
Pydantic v2
													from pydantic import BaseModel, Field
                      from typing import Literal
                      
                      class ChatRequest(BaseModel):
                          model: str
                          max_tokens: int = Field(
                              default=1024, ge=1, le=8192
                          )
                          messages: list[Message]
                          temperature: float = Field(
                              default=0.7, ge=0.0, le=1.0
                          )
                      
                          class Config:
                              # Extra fields raise validation error
                              extra = "forbid"
                      												

Validation in Action

This is Pydantic's core value: define a model once and every instantiation is validated and type-coerced automatically — think of it as FluentValidation and model binding combined into the type declaration itself. Watch two things below: a "512" string is silently coerced to an int, and an out-of-range or invalid value raises a ValidationError with a precise field path, exactly the kind of loud failure you want when parsing untrusted LLM output.

   from pydantic import BaseModel, Field, ValidationError
                         from typing import Literal
                         class Message(BaseModel):
                             role: Literal["user", "assistant", "system"]
                             content: str = Field(min_length=1)
                         class ChatRequest(BaseModel):
                             model: str
                             messages: list[Message] = Field(min_length=1)
                             max_tokens: int = Field(default=1024, ge=1, le=8192)
                             temperature: float = Field(default=0.7, ge=0.0, le=1.0)
                             # Valid instantiation — type coercion happens automatically
                             req = ChatRequest(
                              model="gpt-4o",
                              messages=[{"role": "user", "content": "Hello"}],  # dict → Message auto-coerced
                              max_tokens="512",  # str → int auto-coerced
                             )
                             print(req.max_tokens)  # 512 (int, not str)
                             print(type(req.max_tokens))  # 
                             
                             # Invalid — raises ValidationError with clear messages 
                             try: 
                                  bad=ChatRequest( model="gpt-4o" , 
                                  messages=[{"role" :"admin" ,"content" :"test" }], # invalid role 
                                  max_tokens=99999, # exceeds le=8192 
                             ) 
                             except ValidationError as e: 
                                  print(e) # detailed error with field paths and message
                         

Field() for Constraints and Metadata

Field() is where you attach constraints and documentation to a field — bounds (ge, le), string patterns, descriptions, and examples — the declarative equivalent of data-annotation attributes like [Range] and [Required] in .NET. The second pattern, Annotated aliases, lets you define a constrained type like TokenCount once and reuse it across many models instead of repeating the same bounds.

													from pydantic import BaseModel, Field
                      from typing import Annotated
                      
                      # Method 1: Field() directly
                      class ModelConfig(BaseModel):
                          name: str = Field(
                              ...,                          # ... = required, no default
                              description="OpenAI model name",
                              pattern=r"^gpt-",             # regex validation
                              examples=["gpt-4o"],
                          )
                          max_tokens: int = Field(default=1024, ge=1, le=8192, description="Max output tokens")
                          temperature: float = Field(default=0.7, ge=0.0, le=1.0)
                          top_p: float | None = Field(default=None, ge=0.0, le=1.0)
                      
                      # Method 2: Annotated (preferred in v2 — reusable)
                      PositiveInt = Annotated[int, Field(ge=1)]
                      TokenCount = Annotated[int, Field(ge=0, le=1_000_000)]
                      ScoreFloat = Annotated[float, Field(ge=0.0, le=1.0)]
                      
                      class EvalResult(BaseModel):
                          prompt_id: str
                          score: ScoreFloat
                          input_tokens: TokenCount
                          output_tokens: TokenCount
                          rank: PositiveInt
                      												

Validators: Custom Business Logic

When a constraint can't be expressed as a simple bound, you write a validator method. A @field_validator runs custom logic on one field after type-checking (rejecting whitespace-only queries, say), while a @model_validator sees the whole object and can enforce rules that span fields. This is where domain rules that don't fit an attribute live.

													from pydantic import BaseModel, Field, field_validator, model_validator
                      from typing import Any
                      
                      class RAGRequest(BaseModel):
                          query: str = Field(min_length=1, max_length=10_000)
                          top_k: int = Field(default=5, ge=1, le=50)
                          min_score: float = Field(default=0.7, ge=0.0, le=1.0)
                          filters: dict[str, str] = Field(default_factory=dict)
                      
                          @field_validator("query")
                          @classmethod
                          def query_not_only_whitespace(cls, v: str) -> str:
                              """Field-level validator — runs after type checking."""
                              stripped = v.strip()
                              if not stripped:
                                  raise ValueError("query cannot be only whitespace")
                              return stripped  # return the cleaned value
                      
                          @model_validator(mode="after")
                          def validate_score_top_k_combo(self) -> "RAGRequest":
                              """Model-level validator — access to all fields."""
                              if self.min_score > 0.95 and self.top_k > 10:
                                  raise ValueError(
                                      "With min_score > 0.95, top_k > 10 is unlikely to return results"
                                  )
                              return self
                      
                      
                      # Test validation
                      try:
                          RAGRequest(query="   ", top_k=3)  # fails field_validator
                      except Exception as e:
                          print(e)  # 1 validation error for RAGRequest → query → Value error, query cannot be only whitespace
                      												

Serialization: to_dict and JSON

Pydantic v2 has fast, built-in serialization. This is critical for AI apps that need to store results, send structured responses, or log data:

													from pydantic import BaseModel
                      from datetime import datetime
                      import json
                      
                      class LLMResponse(BaseModel):
                          text: str
                          model: str
                          input_tokens: int
                          output_tokens: int
                          finish_reason: str
                          created_at: datetime = Field(default_factory=datetime.now)
                      
                          @property
                          def total_tokens(self) -> int:
                              return self.input_tokens + self.output_tokens
                      
                      
                      response = LLMResponse(
                          text="RAG stands for Retrieval-Augmented Generation...",
                          model="gpt-4o",
                          input_tokens=45,
                          output_tokens=120,
                          finish_reason="end_turn",
                      )
                      
                      # Serialize to dict
                      d = response.model_dump()
                      print(d)  # {'text': '...', 'model': '...', 'input_tokens': 45, ...}
                      
                      # Serialize to JSON string
                      json_str = response.model_dump_json()
                      print(json_str)
                      
                      # Control serialization
                      d_short = response.model_dump(
                          include={"text", "model"},          # only these fields
                          exclude={"created_at"},             # exclude these
                          mode="json",                        # serialize dates as ISO strings
                      )
                      
                      # Deserialize from dict or JSON
                      same_response = LLMResponse.model_validate(d)
                      from_json = LLMResponse.model_validate_json(json_str)
                      												

Structured Outputs from LLMs

One of Pydantic's most powerful use cases in AI engineering is parsing structured outputs from LLMs. When you ask Claude to return JSON, Pydantic validates and deserializes it in one step:

													from pydantic import BaseModel, Field
                      from openai import OpenAI
                      import json
                      
                      class CodeReviewResult(BaseModel):
                          """Expected structure for LLM code review responses."""
                          overall_score: int = Field(ge=1, le=10)
                          issues: list[str] = Field(default_factory=list)
                          suggestions: list[str] = Field(default_factory=list)
                          approved: bool
                          summary: str
                      
                      
                      def review_code(client: OpenAI, code: str) -> CodeReviewResult:
                          """Ask Claude to review code and parse structured output."""
                      
                          prompt = f"""Review the following Python code and respond with a JSON object matching this exact schema:
                      {{
                        "overall_score": ,"issues" : ["list of specific issues found"],"suggestions" : ["list of improvement suggestions"],"approved" :
                      														= 7>,
                        "summary": "one-sentence overall assessment"
                      }}
                      
                      Code to review:
                      ```python
                      {code}
                      ```
                      
                      Respond with ONLY the JSON object, no other text."""
                      
                          response = client.chat.completions.create(
                              model="gpt-4o",
                              max_tokens=1024,
                              messages=[{"role": "user", "content": prompt}],
                          )
                      
                          raw_json = response.choices[0].message.content.strip()
                      
                          # Parse and validate in one step
                          try:
                              return CodeReviewResult.model_validate_json(raw_json)
                          except Exception as e:
                              raise ValueError(f"LLM returned invalid JSON: {e}\nRaw: {raw_json[:200]}")
                      
                      
                      # Usage
                      result = review_code(client, "def add(a, b):\n    return a + b")
                      print(f"Score: {result.overall_score}/10")
                      print(f"Approved: {result.approved}")
                      for issue in result.issues:
                          print(f"  - {issue}")
                      													

Nested Models and Composition

Real API payloads are nested — a response contains usage stats, a list of message objects, and metadata. Pydantic models compose naturally: use one model as the type of a field in another, and validation recurses through the whole tree automatically. This is how you turn a deeply nested JSON blob from an LLM into a fully validated object graph in one Model(**data) call.

															from pydantic import BaseModel, Field
                      from typing import Optional
                      
                      class TokenUsage(BaseModel):
                          input_tokens: int
                          output_tokens: int
                      
                          @property
                          def total(self) -> int:
                              return self.input_tokens + self.output_tokens
                      
                      
                      class RetrievedSource(BaseModel):
                          chunk_id: str
                          document_id: str
                          score: float = Field(ge=0.0, le=1.0)
                          text_preview: str = Field(max_length=500)
                      
                      
                      class RAGResponse(BaseModel):
                          answer: str
                          sources: list[RetrievedSource] = Field(default_factory=list)
                          usage: TokenUsage
                          model: str
                          query_embedding_ms: Optional[float] = None
                          retrieval_ms: Optional[float] = None
                          generation_ms: Optional[float] = None
                      
                          @property
                          def total_latency_ms(self) -> float | None:
                              parts = [self.query_embedding_ms, self.retrieval_ms, self.generation_ms]
                              if all(p is not None for p in parts):
                                  return sum(parts)  # type: ignore[arg-type]
                              return None
                      														

Key Takeaways

  • Pydantic v2 validates on instantiation, coerces compatible types, and raises ValidationError with detailed field-path messages
  • Use Field(ge=..., le=..., min_length=..., pattern=...) for rich constraints — equivalent to data annotations in C#
  • @field_validator for single-field logic; @model_validator(mode="after") for cross-field invariants
  • model_dump() and model_dump_json() serialize to dict and JSON; model_validate() and model_validate_json() deserialize
  • Use Pydantic to parse and validate LLM structured outputs — combine with model_validate_json() to turn raw LLM text into typed objects
  • Annotated types (ScoreFloat = Annotated[float, Field(ge=0, le=1)]) create reusable constrained types — use them across multiple models

Comments

Loading comments…

Leave a comment