Skip to content
intermediate Phase 2 · Python for AI Engineering

JSON Handling and Pydantic Models

Validate and parse model output with schemas that fail loudly on malformed data.

45m
0 problems
Topic Progress 0%

Model Output Is Untrusted Input

A tool-calling pipeline handed json.loads a model response and got JSONDecodeError: Expecting ',' delimiter. The string looked like perfect JSON: the model had wrapped the object in a fenced code block labeled json and appended "Anything else?" after the closing brace. The parser was correct; the assumption that model text equals JSON was wrong.

What counts as acceptable — and what to do with the rest — gets decided at the boundary where a string becomes a record.

What raw output actually contains

Defect How it appears What json.loads reports
Fenced block fence lines around the object Extra data
Trailing prose object, then a sentence Extra data
Single quotes {'a': 1} Expecting property name enclosed in double quotes
Truncated output {"a": 1, "b": Expecting value
Line comments {"a": 1} // note Extra data

Strip what is decoration, reject what is damage

import json, re

def extract_json(raw: str) -> dict:
    raw = raw.strip()
    fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", raw, re.DOTALL)
    if fence:
        raw = fence.group(1)                      # fences are decoration: safe to remove
    start, end = raw.find("{"), raw.rfind("}")
    if start == -1 or end == -1:
        raise ValueError("no JSON object in model output")
    return json.loads(raw[start:end + 1])         # prose outside the braces falls away

Input:

Here is the extraction:
```json
{"title": "Idempotency", "score": 0.92}
```
Hope that helps!

Output: {'title': 'Idempotency', 'score': 0.92}

Three decisions inside fifteen lines:

  • The fence regex is anchored ^...$, so a code block inside the JSON cannot be mistaken for the wrapper.
  • find/rfind bracket the object instead of trusting the whole string, so trailing chatter is excluded rather than fatal.
  • Truncation still fails: on an unclosed object, raw[start:end + 1] raises JSONDecodeError. Returning {} would push the damage downstream, where the traceback lands on innocent code.

Parsing yields a dict. It says nothing about whether score is present, in range, or remotely the right type — and nothing yet stops a bad value from reaching arithmetic three frames later.

A Schema That Argues Back

Once the string parses, the dict still carries no promises: keys may be missing, numbers may be strings, and nothing complains until round(score * 100) raises TypeError — far from the data that caused it. A model moves that complaint to the boundary where the data entered.

Declare the record once

from pydantic import BaseModel, ValidationError

class Summary(BaseModel):
    title: str
    score: float
    tags: list[str] = []          # a default makes the field optional, not absent-tolerant

good = Summary(title="Idempotency", score=0.92)
print(good.model_dump())

Output: {'title': 'Idempotency', 'score': 0.92, 'tags': []}

try:
    Summary(title="Idempotency", score="high")
except ValidationError as exc:
    print(exc)

Output:

1 validation error for Summary
score
  Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='high']

Read that block like a coordinate system:

  • for Summary — which model rejected the data; matters the moment models nest.
  • score — the exact field, as a path: items.0.price for lists inside models.
  • type=float_parsing — machine-readable, so a test can assert on the kind of failure instead of message text that shifts between releases.

The comparison that makes the case

Bare dict Pydantic model
Missing required key KeyError wherever it is first used ValidationError at construction
Wrong type surfaces at the arithmetic, frames away surfaces at the field, immediately
Extra keys silently kept governed by model_config
Introspection grep the call sites Summary.model_json_schema()

model_json_schema() emits JSON Schema — the same artifact you can pass to a model when requesting Schema-Constrained Generation, so the parser's expectations and the prompt's contract come from one declaration.

Two settings deserve a deliberate choice: model_config = ConfigDict(extra="forbid") turns an unexpected key into an error, and strict validation refuses conversions like "42" → 42 that the default lax mode accepts. Strict for types you control, lax where numbers arrive from forms and CSV cells as strings.

The schema stops type errors. It does not know that an email needs @, that priority has three legal values, or that a similarity above 1.0 means the embedding call misfired.

Business Rules Live in the Model

Types stop the obviously broken. They do not know that reporter must contain an @ or that a score outside [0, 1] means an upstream call went wrong. Checking those at each call site guarantees two sites eventually disagree; on the model, every consumer inherits one rule.

Rules declared next to the field

from enum import Enum
from pydantic import BaseModel, EmailStr, field_validator

class Priority(str, Enum):
    low = "low"
    high = "high"
    urgent = "urgent"

class Ticket(BaseModel):
    reporter: EmailStr
    priority: Priority
    score: float

    @field_validator("score")
    @classmethod
    def score_in_range(cls, v: float) -> float:
        if not 0.0 <= v <= 1.0:
            raise ValueError(f"score must be in [0, 1], got {v}")
        return v

Ticket(reporter="ada@example.com", priority="high", score=0.71)   # passes
Ticket(reporter="ada@example.com", priority="urgent", score=1.4)  # fails below

Output of the second call:

1 validation error for Ticket
score
  Value error, score must be in [0, 1], got 1.4 [type=value_error, input_value=1.4]

Three mechanisms, three jobs:

  • EmailStr and Priority reuse rules that already exist. An Enum doubles as a type check: "URGENT" fails as a non-member, and ticket.priority.value serializes back to the wire string.
  • @field_validator("score") runs after type coercion, so v arrives as a float and the range check never parses anything. Reach for mode="before" only when the raw input must be reshaped first.
  • Raising ValueError produces an indexed ValidationError entry carrying your message. Returning None would silently replace the bad value and turn a detectable defect into an invisible one.

Where a rule must not live

A validator is synchronous and in-process: no API call to confirm the email, no database lookup for the ticket ID, no sleeping. Construction that blocks for thirty seconds turns every parse into an outage risk, and a model built in a request handler puts that latency on the hot path. Cross-system checks belong in a service layer with their own timeout — while validators stay pure, deterministic, and fast, which is what makes enforcing them on every record free.

One class of rejection remains: noisy but harmless inputs — a labeled email with mixed case, an optional field submitted blank. Those should not reach the validators at all.

Sanitize Before You Reject

A field typed float rejects " Score: 0.85 " — and every slightly messy but reasonable value arriving from spreadsheets, form fields, and model text. Rejecting noise that could be normalized cheaply burns engineer time on false alarms while real defects scroll past.

Normalize before validating

from pydantic import BaseModel, field_validator

class Extract(BaseModel):
    email: str
    score: float | None = None

    @field_validator("email", mode="before")
    @classmethod
    def clean_email(cls, v):
        if isinstance(v, str):
            v = v.strip().lower().split("<")[-1].rstrip(">")
        return v                                   # "  <Ada@Example.com> " -> "ada@example.com"

    @field_validator("score", mode="before")
    @classmethod
    def blank_to_none(cls, v):
        return None if v == "" else v              # absent data is None, not an error

print(Extract(email="  <Ada@Example.com>  ", score=""))

Output: Extract(email='ada@example.com', score=None)

mode="before" is the whole difference: these hooks see raw input before coercion, so two short methods erase three classes of noise — case, whitespace, empty strings. score="high" still fails, because normalization never invents a number the input did not contain.

Repair or reject

Input defect Example Treatment
Whitespace and case " ADA@x.io " normalize, then validate
Missing optional field no score default to None, document it
Convertible type from a messy source "0.85" allow lax coercion
Wrong type, meaning intact score="high" reject with the field path
Out-of-domain value score=1.4 reject — a rule, not a formatting issue
Missing required field no email reject at construction

The dividing line: formatting defects are the input's fault and worth repairing; semantic defects carry the data's meaning and must reach the caller as errors. A pipeline that clamps score=1.4 to 1.0 converts a measurable bug into an invisible one.

With the boundary raising the right exceptions, the suite in Testing AI Code can assert on them without the network — and Environments and Dependency Hygiene keeps the pydantic version identical on every machine.

Quiz

1. json.loads reports `Extra data: line 2 column 1` on a model response. What is the most likely cause?

Question 1 options

2. By default, when does a @field_validator run relative to type coercion?

Question 2 options

3. A ValidationError shows `loc = ('items', 0, 'price')`. What does that give you?

Question 3 options

4. Which input should be rejected outright rather than normalized before validation?

Question 4 options

Flashcards

Question

What is the boundary rule for model output?

Answer

Parse defensively first — strip fences, bracket the object away from prose — then validate the record. A dict that json.loads returned is not yet a record you can trust.

Question

What does a ValidationError contain?

Answer

The model name, the loc path to the failing field (e.g. ('items', 0, 'price')), the raw input value, and a machine-readable type= — assert on type and loc, not message prose.

Question

Default field_validator mode versus mode="before"

Answer

Default runs after coercion on the converted value — good for range rules. mode="before" sees raw input — good for stripping, case folding, and blank-to-None normalization.

Question

Repair or reject — where is the line?

Answer

Formatting defects (case, whitespace, blank optional fields) get normalized; semantic defects (wrong type, out-of-domain value, missing required field) must reach the caller as errors.

Question

Why never validate across the network inside a validator?

Answer

Validators are synchronous and in-process; a network or DB call would block every construction and put that latency on the hot path. Cross-system checks belong in a service layer with their own timeout.

Question

Why raise ValueError in a validator instead of returning None?

Answer

Raising produces an indexed ValidationError entry carrying your message; returning None silently replaces the bad value and converts a detectable defect into an invisible one.

Revision Notes

Key Takeaways

  • 1. Model text is untrusted input: strip decoration, bracket the object, and let truncation fail loudly instead of defaulting to {}
  • 2. A Pydantic model moves failure from wherever the value is first used to the moment the record is constructed
  • 3. field_validator default mode runs after coercion; mode="before" is for reshaping raw input
  • 4. Normalize formatting noise early so semantic defects stay the only thing validation reports
  • 5. Validators stay pure and local — anything needing I/O moves to a service layer

Interview Tips

  • • Write the defensive parser on a whiteboard — fence regex, find/rfind bracketing — and say explicitly why truncation must still raise
  • • Explain ValidationError by its parts: model, loc path, input value, type code — then say which two a test should assert on
  • • Be ready for the repair-versus-reject question with a concrete pair: whitespace in an email is normalized, score=1.4 is not
  • • Expect the follow-up on where validation should live if it needs the database — the answer separates model construction from service checks

Cheat Sheet

JSON Handling and Pydantic Models — Cheat Sheet

Parse defensively

import json, re

raw = raw.strip()
m = re.match(r"^```(?:json)?\s*(.*?)\s*```$", raw, re.DOTALL)   # strip the fence if present
if m:
    raw = m.group(1)
obj = json.loads(raw[raw.find("{"): raw.rfind("}") + 1])         # bracket prose away

Declare and enforce

class Ticket(BaseModel):
    reporter: EmailStr
    priority: Priority                 # Enum doubles as a type check
    score: float
    tags: list[str] = []

    @field_validator("score")                    # AFTER coercion: v is already float
    @classmethod
    def in_range(cls, v):
        if not 0 <= v <= 1:
            raise ValueError(f"score must be in [0, 1], got {v}")
        return v

    @field_validator("reporter", mode="before")  # BEFORE coercion: reshape raw input
    @classmethod
    def clean(cls, v):
        return v.strip().lower().split("<")[-1].rstrip(">") if isinstance(v, str) else v

Reading a ValidationError: for <Model> → which schema · loc path → which field (items.0.price) · type= → assert on the type, not the message text.

Repair vs reject

Defect Treatment
whitespace, case, blank optional strings normalize in mode="before"
convertible type from a known-messy source lax coercion (default)
wrong type ("high" → float), out-of-range value reject
missing required field reject at construction
extra keys from the provider model_config = ConfigDict(extra="forbid")

Never reach the network or the database inside a validator — it is synchronous and in-process; those checks belong in a service layer with their own timeout.

Failure modes worth recognizing on sight: Extra data → trailing prose or fence · Expecting value → truncation · float_parsing → a number arrived as text · value_error → a business rule rejected a well-typed value.

Continue Learning

Before You Start

  • No prerequisites listed.

Next Topics

Related Reading