Skip to content

    Forward Deployed Engineer

    Structured Outputs: JSON Schema and Pydantic Validation

    Get typed data out of an LLM: derive a JSON schema from a Pydantic model, validate every reply, repair once, then fail clearly, and count what the retry costs.

    By Published 8 min read

    • fde
    • structured-output
    • pydantic
    • json-schema
    • validation
    • python
    Share

    This lesson is part of Forward Deployed Engineer: Build LLM Features. Enroll for free, then sign in, to read it in full.

    What you'll learn: how to ask a model for JSON and still not trust it. You derive a JSON schema from a Pydantic model, validate every reply, classify what can go wrong, make exactly one bounded repair attempt, return a clear error when that fails, and measure what the repair costs. The capstone does its validation with Pydantic itself, not with the jsonschema package.

    At a glance

    Figure 1. Validate, repair once, then fail

    stateDiagram-v2 accTitle: Validate and repair once accDescr: A reply is received and checked. If it parses and matches the schema it is valid. Otherwise the caller makes one repair call that sends back the bad reply and the problem. If the second reply is valid the result is valid. If it is not, the request fails with a 502 error. There is no third attempt. [*] --> Asked Asked --> Checking: reply received Checking --> Valid: parses and matches schema Checking --> Repairing: not JSON or wrong shape Repairing --> Rechecking: one repair call Rechecking --> Valid: parses and matches schema Rechecking --> Failed: still invalid Valid --> [*] Failed --> [*]: 502 llm_invalid_output

    How to read this: two checks and one repair; the failed state is a normal outcome the API reports.

    • A model reply is text: treat it as untrusted input, like a request body.
    • A Pydantic model gives you the type, the validation and the JSON schema at once.
    • A reply fails in two ways: not JSON, or JSON of the wrong shape.
    • Retry once with the problem stated; never loop. Each retry is a billed call.
    • Even a provider's constrained-output feature does not remove the need to validate.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply turns each incoming request into a requirements record: category, quantity, budget, protocols. The next steps (search, compatibility checks) need exact fields. During a pilot, a few model replies come back with a sentence before the JSON, one with a negative quantity, and one with the budget as a string. The team asks what the service should do with each. All figures are illustrative.

    What you will be able to do

    • Derive a JSON schema from a Pydantic model and read its constraints.
    • Classify a reply as valid, not JSON or wrong shape, and say what Pydantic does by default with strings and extra keys.
    • Implement and test one bounded repair retry, and compute what it adds to the cost.

    Prerequisites

    Read Typed data models: dataclasses and Pydantic for field constraints and Prompts and untrusted input for why a reply is untrusted. The architecture course has no page on this topic, so everything is here.

    Core ideas

    One model, three jobs

    Write the shape once as a Pydantic class; it validates replies and produces the JSON schema you send with the request. The capstone's Requirements has every field optional, with constraints on two.

    Verified: capstone/fde-product-discovery-copilot/app/models.py

    class Requirements(BaseModel):
        """What the customer asked for, as structured data. Every field is optional."""
    
        category: Category | None = None
        quantity: int | None = Field(default=None, gt=0)
        budget_eur: float | None = Field(default=None, gt=0)
        protocols: list[str] = Field(default_factory=list)
        environment: list[str] = Field(default_factory=list)
        must_have: list[str] = Field(default_factory=list)
        nice_to_have: list[str] = Field(default_factory=list)
        keywords: list[str] = Field(default_factory=list)  # topic words, for example "vibration"
        skus: list[str] = Field(default_factory=list)  # products named in the request (resolved in code)

    Requirements.model_json_schema() returns a plain dict. As Pydantic documents, gt=0 becomes exclusiveMinimum: 0, an optional field allows null, and a field with a default is not required. The quantity property from a real run:

    {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "title": "Quantity"}
    Concern Pydantic here Rough Java equivalent
    Declare the shape a class with typed fields a record or class
    Constraints Field(gt=0, pattern=...) Bean Validation annotations
    Parse and validate JSON model_validate_json, or json.loads then model_validate Jackson, then a validator

    Three things can happen to a reply

    Predict first. Does Pydantic accept each reply for Requirements: (a) {"quantity": "40"}, (b) {"quantity": 40.5}, (c) {"quantity": 3, "ignore_the_rules": true}?

    Answer

    (a) Accepted, and quantity becomes the integer 40: Pydantic's default lax mode converts a numeric string. (b) Rejected: a float is accepted for an int only without a fractional part. (c) Accepted, and the extra key is silently dropped by default. Strict mode and extra="forbid" change (a) and (c).

    Reply text Verdict Why
    {"category": "sensors", "quantity": 3} ok parses and matches
    ```json ... ``` fenced ok the capstone strips a Markdown fence first
    Sure! Here you go: {...} not_json prose around the JSON cannot be parsed
    {"quantity": -4} wrong_shape gt=0 fails
    {"category": "toasters"} wrong_shape not one of the allowed categories
    [1, 2, 3] wrong_shape valid JSON, but not an object

    The capstone treats both failures alike, because json.JSONDecodeError is a subclass of ValueError and Pydantic raises ValidationError for the rest. Still log the distinction: a rise in not_json points at the prompt or truncation; a rise in wrong_shape points at the schema.

    NOTE

    As of 2026-11-02 the Anthropic documentation describes a mode that constrains generation to a schema, and also says the output may still not match it in some cases (a refusal, or a reply cut off at the token limit) and that some constraints, such as numeric bounds, are not supported. The capstone's reference adapter does not use it: it appends the schema to the system message as text and has never been run against the live API. Validate in your code either way.

    The bounded repair

    Figure 2. The request with its single repair

    sequenceDiagram accTitle: One request with one repair accDescr: The caller sends the messages and the schema to the provider and gets text back. The caller validates it. If it is invalid, the caller sends the original messages, the bad reply as an assistant message, and a repair instruction. The provider replies again. The caller validates once more and either returns the typed result or raises a 502 error. participant C as Caller code participant P as Provider C->>P: messages and schema P-->>C: reply text C->>C: validate against the model C->>P: same messages, the bad reply and a repair instruction P-->>C: second reply text C->>C: validate again C-->>C: typed result, or StructuredOutputError with 502

    How to read this: the repair call repeats the whole conversation plus the bad reply, so it costs more than the first call.

    A loop that retries until the model gets it right has no bound on cost or latency. One repair catches the common slips (prose around the JSON, a missing field); everything else becomes a clear error your API maps to a status code.

    In the interview: "The model sometimes returns invalid JSON. What do you do?" A strong answer validates against a schema in code, makes at most one repair attempt with the problem stated, fails with a specific error (not a stack trace), logs the kind of failure, counts the retry in cost and latency, measures the repair rate on real traffic, and says what the user sees next.

    Worked example

    Assumptions: the mock provider, with the capstone's token estimate (1 word = 1.3 tokens, rounded up); placeholder prices of 1.0 and 5.0 EUR per million input and output tokens (replace with your provider's published prices); the pumps request; a first reply of not json {. Numbers come from the lesson tests.

    Path Calls Input tokens Output tokens Cost (EUR, placeholder)
    Valid first reply 1 77 32 0.000237
    One repair 2 188 36 0.000368

    The repair path costs (188 x 1.0 + 36 x 5.0) / 1,000,000 = 368 / 1,000,000 = 0.000368, which is 55 percent more than 0.000237. The second call carries the system message, the request, the bad reply and the repair instruction.

    If 2 percent of requests needed a repair, the extract step would cost about 1.1 percent more on average (0.02 x 0.55). The 2 percent is made up for the arithmetic: measure yours by logging calls per structured step.

    Build it

    Verified: capstone/fde-product-discovery-copilot/app/llm/structured.py

    def parse_json_model(model: type[T], text: str) -> T:
        """Strip a Markdown fence if present, then validate against the Pydantic model."""
        return model.model_validate(json.loads(_FENCE.sub("", text.strip())))
    
    
    def complete_structured(provider: LLMProvider, messages: Sequence[Message], model: type[T]) -> T:
        schema = model.model_json_schema()
        first = provider.complete(messages, schema)
        try:
            return parse_json_model(model, first.text)
        except (ValueError, ValidationError) as err:  # json.JSONDecodeError is a ValueError
            problem = str(err).splitlines()[0][:300]
        repair = [
            *messages,
            Message("assistant", first.text),
            Message("user", f"REPAIR: your reply was not valid ({problem}). Reply again with only JSON that matches the schema."),
        ]
        second = provider.complete(repair, schema)  # exactly one retry, never a loop
        try:
            return parse_json_model(model, second.text)
        except (ValueError, ValidationError):
            raise StructuredOutputError("The model did not return valid structured output after one repair attempt.") from None

    Now test the bound. The lesson test counts calls through a metering wrapper, checks that the bad reply goes back as an assistant message and that a third reply is never requested.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L3/test_m2_l3_validation.py

    def test_one_repair_means_two_calls_billed() -> None:
        llm, inner, meter = _metered(["not json {", GOOD])
        assert complete_structured(llm, extract_messages("x"), Requirements).quantity == 3
        assert (meter.llm_calls, meter.input_tokens, meter.output_tokens) == (2, 200, 40)
        assert [m.role for m in inner.calls[1]][-2:] == ["assistant", "user"]  # the bad reply is sent back
    
    
    def test_two_bad_replies_raise_after_exactly_two_calls() -> None:
        llm, inner, _ = _metered(["nope", "still nope", GOOD])
        with pytest.raises(StructuredOutputError) as caught:
            complete_structured(llm, extract_messages("x"), Requirements)
        assert caught.value.status == 502 and len(inner.calls) == 2 and inner.texts == [GOOD]

    Look at the repair message. For a Pydantic error the first line is only the count and model name, so the model is told "1 validation error for Requirements", not which field. A test documents this:

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L3/test_m2_l3_validation.py

    def test_the_capstone_repair_message_does_not_name_the_field() -> None:
        inner = Scripted([BAD_FIELD, GOOD])
        complete_structured(inner, extract_messages("x"), Requirements)
        repair = inner.calls[1][-1].content
        assert "1 validation error for Requirements" in repair and "quantity" not in repair

    Run the lesson tests:

    ./.venv/Scripts/python -m pytest lessons/M2-L3 -v

    The last lines of a real run:

    collected 16 items
    
    lessons\M2-L3\test_m2_l3_validation.py ................                  [100%]
    
    ============================= 16 passed in 2.68s ==============================

    Exercise

    Improve the repair message without touching app/. Write problem_hint(err) so a Pydantic error lists each field path with its message, for example quantity: Input should be greater than 0, and never the submitted value (it may be private). Then write a copy of complete_structured that uses it.

    Acceptance criteria:

    • A test shows the repair message names quantity and the constraint, and does not contain the submitted -4.
    • A reply that is not JSON still gets a useful hint.
    • Two bad replies still raise StructuredOutputError after exactly two calls.

    Done when the three tests pass and you can say why the value must stay out of the message.

    Hint 1: where is the detail?

    ValidationError.errors() returns a list of dicts, each with a loc (a tuple of path parts) and a msg. Look at the keys for a failing quantity.

    Hint 2: build the string

    Join loc parts with a dot, add a colon and the msg, join items with a semicolon, cut to 300 characters. Keep the old first-line behavior for other errors. Do not read the input key.

    Common mistakes

    • Retrying in a loop until the reply is valid. Cost and time have no bound.
    • Catching only ValidationError and letting JSONDecodeError escape as a 500.
    • Skipping validation because the provider offers a constrained mode. Replies can still be cut off or refused.
    • Sending a possibly private submitted value back to the model in the repair message.

    Knowledge check

    1. Why validate a model's JSON at all if you asked for JSON?

    The reply is text from a probabilistic system: it can contain prose, be cut off or break a constraint. Later steps expect exact fields, so an invalid record would fail far from its cause. Validation at the boundary gives one clear, early error.

    2. Name the two ways a reply fails and one place you would see each in the logs.

    Not JSON (prose around the JSON, truncation) and wrong shape (it parses but breaks the schema, such as a negative quantity). Record the kind with the failure: the first points at the prompt or token limits, the second at the schema.

    3. A reply is `{"quantity": "40"}`. What does the capstone do, and when would you change it?

    It accepts it and sets quantity to 40, because lax mode converts a numeric string to an int. Use strict mode when a wrong type signals a real problem and silent conversion would hide a data bug.

    4. The repair path used 188 input tokens and the clean path 77. Why more than double?

    The 188 is both calls added: 77 for the first and 111 for the repair call, which sends the whole conversation again plus the bad reply and the repair instruction. Cost rises only 55 percent (0.000237 to 0.000368), because output tokens cost five times as much per token here and barely grew (32 to 36).

    5. Why must the repair stop after one attempt, and what should the API return after that?

    Each attempt adds cost and latency, and a second failure signals a problem with the input or prompt. The capstone raises a specific error mapped to a 502 with the code llm_invalid_output, so the client can show a clear message or use a fallback. A loop would hide the failure rate and the bill.

    You can now

    • Generate and read a JSON schema from a Pydantic model.
    • Classify a reply as valid, not JSON or wrong shape.
    • Implement and test one bounded repair, and count its cost.
    • Write a repair message that does not leak the submitted value.

    Further reading

    Last verified: 2026-11-02

    Next

    Calling a provider: an abstraction, a mock and limits

    Enroll to keep reading

    Lesson 3 of 7 · Forward Deployed Engineer: Build LLM Features

    Get typed data out of an LLM: derive a JSON schema from a Pydantic model, validate every reply, repair once, then fail clearly, and count what the retry costs.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Build LLM Features, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments