TechByteByByte

Structured Output, Properly: Two Real Strategies

The dedicated deep dive on structured output — Pydantic schemas, the two real mechanisms LangChain uses underneath, nested structures, validation failures, and when to trust it.

#LangChain#Structured Output#Pydantic

You’ve used structured output in pieces since Module 4 — a preview here, a real use in Modules 7 and 9, a mention of provider differences in Module 5. This module finally gives it the full, dedicated treatment it deserves, because it’s one of the most consequential tools in your entire LangChain toolkit: the difference between a model that talks, and a model whose output your code can actually, reliably use.

The problem, restated precisely

Recall Module 4’s first glimpse: asking a model “what is the capital of France” gets you a sentence. Real application code almost never wants a sentence — it wants {"country": "France", "capital": "Paris"}, something it can put directly into a database, a variable, an API response. The gap between “a fluent sentence” and “data my program can use” is exactly what structured output closes.

Example 1: the basic shape, precisely explained

from pydantic import BaseModel
from langchain.chat_models import init_chat_model

class MovieRecommendation(BaseModel):
    title: str
    year: int
    reason: str

model = init_chat_model("openai:gpt-4o-mini")
structured_model = model.with_structured_output(MovieRecommendation)

result = structured_model.invoke("Recommend a good sci-fi movie from the 1980s.")
print(result)
print(type(result))

result is a genuine MovieRecommendation instance — not a dictionary, not a string that looks like one. result.title, result.year, result.reason are real, typed attributes, validated by Pydantic before you ever see them.

Example 2: nested structures

Real data is rarely flat. Structured output handles genuine nesting cleanly.

from pydantic import BaseModel
from langchain.chat_models import init_chat_model

class Address(BaseModel):
    city: str
    country: str

class Person(BaseModel):
    name: str
    age: int
    address: Address

model = init_chat_model("openai:gpt-4o-mini").with_structured_output(Person)
result = model.invoke("Extract: Maria is 34 and lives in Lisbon, Portugal.")
print(result)
print(result.address.city)

Notice Person contains a full Address object, not just flat fields. The model has to correctly produce a nested structure, and Pydantic validates the whole thing, at every level, before handing it back.

Example 3: optional fields and lists

from pydantic import BaseModel
from langchain.chat_models import init_chat_model

class MeetingNotes(BaseModel):
    summary: str
    action_items: list[str]
    follow_up_date: str | None

model = init_chat_model("openai:gpt-4o-mini").with_structured_output(MeetingNotes)
result = model.invoke(
    "We discussed the Q3 roadmap. Sarah will finalize the budget. No follow-up scheduled yet."
)
print(result)

list[str] tells the model to produce a genuine list, not a single string it’s up to you to split. str | None tells it this field can genuinely be absent — worth using deliberately whenever a piece of information might not always exist in the input, rather than forcing the model to invent a value.

Example 4: what happens when validation genuinely fails

It’s worth seeing this honestly, not just the happy path.

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model

class Rating(BaseModel):
    stars: int = Field(ge=1, le=5, description="A rating from 1 to 5 stars.")

model = init_chat_model("openai:gpt-4o-mini").with_structured_output(Rating)

try:
    result = model.invoke("Rate this on a scale of 1 to 5: the food was mediocre, the service was terrible.")
    print(result)
except Exception as e:
    print(f"Validation failed: {e}")

In practice, models generally respect a Field’s constraints reliably, but it’s not an absolute guarantee — a genuinely ambiguous prompt, or an unusual edge case, can occasionally produce a value the schema rejects. Wrapping structured output calls in a try/except in real, production code is a genuinely sensible habit, not paranoia.

Example 5: the two real mechanisms, revisited properly

Recall Module 5’s honest note that .with_structured_output() uses different real strategies underneath, depending on the model. Let’s make this concrete.

from pydantic import BaseModel
from langchain.chat_models import init_chat_model

class Fact(BaseModel):
    claim: str
    is_true: bool

openai_model = init_chat_model("openai:gpt-4o-mini").with_structured_output(Fact)
gemini_model = init_chat_model("google_genai:gemini-2.0-flash").with_structured_output(Fact)

print(openai_model.invoke("Is it true that the Great Wall of China is visible from space?"))
print(gemini_model.invoke("Is it true that the Great Wall of China is visible from space?"))

Both work. Underneath, one model likely uses native, provider-enforced schema constraints; the other may use LangChain’s tool-based fallback, quietly turning your schema into a tool call and reconstructing the object from it. You don’t need to manage this difference yourself — but on a genuinely complex, deeply nested schema, it’s worth testing against your actual target model rather than assuming behavior transfers perfectly from one provider to another.

Example 6: structured output combined with an agent

Recall Module 15’s response_format — this is structured output applied to an agent’s final answer, after it’s freely used tools across however many rounds it needed.

from pydantic import BaseModel
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.agents import create_agent

@tool
def get_temperature(city: str) -> str:
    """Get the current temperature for a city, in Celsius."""
    return "24"

class WeatherSummary(BaseModel):
    city: str
    temperature_celsius: int
    advice: str

agent = create_agent(
    model=init_chat_model("openai:gpt-4o-mini"),
    tools=[get_temperature],
    response_format=WeatherSummary,
)

result = agent.invoke({"messages": [{"role": "user", "content": "What's the temperature in Nairobi, and should I bring a jacket?"}]})
print(result["structured_response"])

This is genuinely the combination worth remembering: the reasoning process stays free-form and multi-step, using tools exactly as needed; the final deliverable your code actually consumes is clean, guaranteed-shaped data.

Common mistakes worth avoiding

Making every field required when some genuinely aren’t always present. Recall Example 3 — forcing a model to fill in a field that truly isn’t in the source text pressures it toward inventing a plausible-sounding but fabricated value. Use | None deliberately, whenever absence is a real, valid possibility.

Writing a schema with vague field names and no descriptions. A field named val: int with no Field(description=...) gives the model almost nothing to work with, echoing Module 12’s lesson about tool descriptions — schemas deserve the same care. Field(description="...") genuinely improves reliability, especially on ambiguous fields.

Never handling the possibility of a validation failure. Recall Example 4 — structured output is reliable, not infallible. Production code that assumes it will never fail is one unusual input away from an unhandled crash.

What you should take away from this module

  • .with_structured_output(Schema) turns a model’s reply into a real, validated, typed object — not a string you parse yourself.
  • Pydantic schemas support nesting, lists, and optional fields, letting you model genuinely realistic, real-world data shapes.
  • Validation can fail, rarely but genuinely — wrap structured output calls in a try/except in real applications.
  • Underneath, different models use different real mechanisms to achieve this — usually invisible to you, but worth testing directly on your actual target model for complex schemas.
  • response_format on create_agent applies this same idea to an agent’s final answer, after its free-form tool use is complete.

Where this goes next

The next module addresses something every agent you’ve built so far has quietly needed: Agent State and Memory — clearing up genuinely confusing, overlapping terminology, and showing exactly which mechanism to reach for depending on what actually needs to be remembered, and for how long.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed