← Back to DevBytes

How to Use Pydantic with LangChain for Structured Output

Introduction to Structured Output with Pydantic and LangChain

Large language models are powerful, but their outputs are inherently unstructured. When you build production applications, you rarely want free-form text — you want typed data that your downstream code can safely consume. This is where combining Pydantic with LangChain becomes essential. Pydantic gives you a declarative way to define schemas, and LangChain provides the orchestration layer that prompts the model, parses the response, and validates it against your schema.

What Is Pydantic?

Pydantic is the most widely used data validation library in the Python ecosystem. It lets you define data models as standard Python classes that inherit from BaseModel. Each field is annotated with a type, and Pydantic automatically validates incoming data, coerces compatible types, and produces rich error messages when validation fails. Because Pydantic models serialize cleanly to JSON Schema, they are the perfect bridge between Python code and LLM tool-calling APIs.

What Is LangChain's Structured Output Feature?

LangChain exposes a method called with_structured_output() on chat model objects. When you pass a Pydantic class (or a JSON Schema dict, or a TypedDict) to this method, LangChain returns a new runnable that guarantees the model's response will conform to your schema. Under the hood, LangChain chooses the best available strategy — native tool-calling, JSON mode, or function-calling — depending on the model provider you are using.

Why Structured Output Matters

Working with raw LLM text is fragile. If you ask a model to "return JSON," it may wrap the JSON in markdown fences, add conversational preamble, or invent fields that don't exist in your schema. Each of these failures forces you to write brittle parsing logic with regex and try/except blocks. Structured output eliminates this entire class of problems.

Setting Up Your Environment

Before writing code, install the required packages. The example below uses OpenAI, but the same patterns apply to any LangChain-supported chat model.

pip install langchain langchain-openai pydantic

Set your API key as an environment variable:

export OPENAI_API_KEY="sk-your-key-here"

Defining a Pydantic Schema

The first step is to define the shape of the data you want the model to produce. Always include Field descriptions — they serve as in-context documentation that guides the model toward correct values.

from pydantic import BaseModel, Field
from typing import List, Optional


class Person(BaseModel):
    """Information about a person mentioned in a text."""

    name: str = Field(description="The full name of the person")
    age: Optional[int] = Field(
        default=None,
        description="The person's age, if mentioned. Omit if unknown."
    )
    occupation: Optional[str] = Field(
        default=None,
        description="The person's job or profession, if mentioned."
    )


class ExtractionResult(BaseModel):
    """The result of extracting person information from a text."""

    people: List[Person] = Field(
        description="A list of every person mentioned in the source text"
    )

Notice how every field carries a description. These descriptions are converted into the JSON Schema that the model sees, so they directly influence output quality. Treat them as part of your prompt engineering.

Binding the Schema to a Chat Model

Once your schema is defined, bind it to a chat model using with_structured_output(). The returned object behaves like a normal LangChain runnable: you call invoke() with a message and receive a fully validated Pydantic instance.

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

structured_llm = llm.with_structured_output(ExtractionResult)

text = """
Sarah Chen is a 34-year-old software engineer who recently joined a startup
called Nimbus. Her colleague, Marcus Webb, is the head of product and
has been in the industry for over a decade.
"""

result = structured_llm.invoke(text)

print(type(result))  # <class '__main__.ExtractionResult'>
print(result.people[0].name)        # Sarah Chen
print(result.people[0].age)         # 34
print(result.people[1].occupation)  # head of product

The model never returns a raw string here. LangChain handles the tool-call or JSON-mode negotiation with the provider, parses the response, and instantiates your Pydantic model. If the model returns data that violates the schema, Pydantic raises a ValidationError immediately.

Using Structured Output Inside a Chain

Structured output becomes even more powerful when composed with other LangChain components. Using LCEL (LangChain Expression Language), you can build a pipeline that takes raw text, applies a prompt template, and returns a typed object.

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are an expert information extraction engine."),
    ("human", "Extract all people mentioned in the following text:\n\n{text}"),
])

chain = prompt | llm.with_structured_output(ExtractionResult)

result = chain.invoke({"text": text})

for person in result.people:
    print(f"{person.name} — age: {person.age}, role: {person.occupation}")

Because the chain ends with a structured output runnable, every call to invoke, batch, or stream returns validated ExtractionResult objects. This makes the chain safe to drop into a larger application — a FastAPI endpoint, a Celery task, or an Airflow DAG.

Handling Enums and Constrained Values

Real-world schemas often need constrained values such as categories or severity levels. Pydantic's Enum support works seamlessly with LangChain's structured output, forcing the model to choose from a fixed set of options.

from enum import Enum


class Sentiment(str, Enum):
    positive = "positive"
    neutral = "neutral"
    negative = "negative"


class SentimentAnalysis(BaseModel):
    """The sentiment analysis result for a single piece of text."""

    sentiment: Sentiment = Field(
        description="The overall sentiment of the text"
    )
    confidence: float = Field(
        description="A confidence score between 0.0 and 1.0",
        ge=0.0,
        le=1.0,
    )
    reasoning: str = Field(
        description="A short explanation of why this sentiment was chosen"
    )


analyzer = llm.with_structured_output(SentimentAnalysis)

review = "The food was incredible but the service was painfully slow."
analysis = analyzer.invoke(f"Analyze this review: {review}")

print(analysis.sentiment)    # Sentiment.neutral
print(analysis.confidence)   # 0.75
print(analysis.reasoning)

The ge and le constraints on confidence are translated into the JSON Schema, so the model is informed of the valid range. If it returns 1.5, Pydantic rejects it before your code ever sees the value.

Nested Models and Complex Schemas

Pydantic models can nest arbitrarily deep, and LangChain handles this without extra configuration. This is useful for tasks like extracting structured data from documents with hierarchical relationships.

class Address(BaseModel):
    street: str = Field(description="Street address")
    city: str = Field(description="City name")
    postal_code: Optional[str] = Field(
        default=None, description="Postal or ZIP code if available"
    )


class Company(BaseModel):
    name: str = Field(description="The company's legal name")
    industry: str = Field(description="Primary industry or sector")
    headquarters: Address = Field(description="The company's main office")
    employee_count: Optional[int] = Field(
        default=None,
        description="Approximate number of employees"
    )


class CompanyReport(BaseModel):
    """A structured report about a company extracted from text."""

    company: Company
    summary: str = Field(
        description="A one-paragraph summary of the company"
    )
    key_facts: List[str] = Field(
        description="A list of notable facts about the company"
    )


reporter = llm.with_structured_output(CompanyReport)

source = """
Acme Robotics is an autonomous-vehicle startup based at 120 Innovation Way,
Palo Alto, CA 94301. Founded in 2019, the company now employs roughly 280
people and focuses on last-mile delivery robots.
"""

report = reporter.invoke(source)

print(report.company.name)                  # Acme Robotics
print(report.company.headquarters.city)     # Palo Alto
print(report.company.employee_count)        # 280
print(report.key_facts)

Nesting is one of the strongest arguments for using Pydantic over plain JSON Schema dictionaries. You get IDE autocompletion, static type checking, and runtime validation all from the same class definitions.

Choosing the Right Method: Tool Calling vs JSON Mode

LangChain's with_structured_output() accepts a method parameter that controls how the model is constrained. Understanding the trade-offs helps you avoid subtle bugs.

# Explicitly choose a method
structured_llm = llm.with_structured_output(
    ExtractionResult,
    method="json_schema",
)

If you omit the method argument, LangChain selects the best option for your model provider automatically. In most cases the default is fine, but being explicit is helpful when you are debugging edge cases.

Best Practices

Write Descriptive Field Documentation

Field descriptions are part of the prompt. A vague description like "the name" produces worse results than "The full legal name of the person, including first and last name". Be specific about format, allowed values, and what to do when information is missing.

Use Optional Fields for Uncertain Data

LLMs frequently encounter text where some information is absent. Marking fields as Optional with a default=None prevents validation errors and lets your code handle missing data gracefully rather than crashing the entire pipeline.

Keep Schemas Focused

Avoid dumping every possible field into a single giant model. Large schemas confuse models and increase the chance of hallucinated or mis-typed values. If you need to extract many different kinds of information, consider running multiple focused chains in parallel instead of one monolithic extraction.

Set Temperature to Zero for Extraction

For deterministic extraction and classification tasks, set temperature=0. This reduces variability between runs and makes your structured output more reproducible, which is critical for testing and evaluation.

Validate and Retry on Failure

Even with structured output, occasional validation failures can occur, especially with weaker models. Wrap your invocation in a retry loop or use LangChain's with_retry() method to add resilience.

from langchain_core.runnables import RunnableLambda

robust_chain = (
    prompt
    | llm.with_structured_output(ExtractionResult)
).with_retry(stop_after_attempt=3)

result = robust_chain.invoke({"text": text})

Test Schemas Independently

Because Pydantic models are plain Python classes, you can unit-test them without involving the LLM at all. Construct sample dictionaries, pass them through ExtractionResult.model_validate(), and assert that validation behaves as expected. This catches schema design issues long before you spend tokens on the model.

Conclusion

Combining Pydantic with LangChain's structured output transforms LLM responses from unpredictable text into reliable, typed Python objects. By defining clear schemas with descriptive fields, choosing the right binding method, and following best practices around optionality, schema focus, and retries, you can build extraction, classification, and reasoning pipelines that are robust enough for production. The small upfront cost of designing a good Pydantic model pays off every time your downstream code can trust the shape of the data it receives — no regex parsing, no defensive try/except blocks, just clean and validated objects flowing through your application.

— Ad —

Google AdSense will appear here after approval

← Back to all articles