In this langchain with_structured_output tutorial we go from zero to a typed LLM extraction pipeline that never hands you a bare string when you expected an object. You’ll define a Pydantic schema, bind it to a chat model, and handle the edge cases that bite in production.
Prerequisites
- Python 3.10 or newer
langchain-openaiandpydanticv2 installed- An OpenAI API key, or any OpenAI-compatible endpoint (see note below)
pip install langchain-openai pydantic
Set your key:
export OPENAI_API_KEY="sk-..."
If you route through an OpenAI-compatible gateway such as n4n.ai, set base_url and the gateway key instead; with_structured_output works identically because the gateway forwards the native structured output parameters.
Step 1: Define the output contract
The first rule of any langchain with_structured_output tutorial is to never trust the model’s shape. Write a Pydantic model that encodes exactly what you need.
from pydantic import BaseModel, EmailStr, Field
class Contact(BaseModel):
name: str = Field(description="Full name of the person")
age: int = Field(description="Age in years", ge=0, le=120)
emails: list[EmailStr] = Field(description="Verified email addresses")
The description fields are not cosmetic—LangChain forwards them as JSON schema descriptions, and many providers use them to steer generation.
Step 2: Bind the schema to a chat model
Create a standard ChatOpenAI instance and call with_structured_output. This returns a new runnable that accepts the same inputs but emits validated objects.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(Contact)
Under the hood, LangChain inspects the model’s capabilities. For OpenAI models it uses function-calling or JSON mode; for models without native support it falls back to a prompt + parser combination, which is strictly worse. Always verify your model supports native structured output.
Step 3: Invoke and inspect
Run a simple extraction:
text = "Sara Connor, 34, reachable at sara@example.com or s.connor@example.org"
result = structured_llm.invoke(text)
print(result)
Expected output:
name='Sara Connor' age=34 emails=['sara@example.com', 's.connor@example.org']
You get a Contact instance, not a dict. That means .emails[0] is typed and EmailStr has already validated the addresses.
Step 4: Get the raw response when you need it
In production you often want the token usage or the original message. Pass include_raw=True:
structured_with_raw = llm.with_structured_output(Contact, include_raw=True)
raw = structured_with_raw.invoke(text)
print(type(raw))
print(raw["parsed"].name)
print(raw["raw"].usage)
raw is a dict with keys parsed (your object or None) and raw (the AIMessage). If validation fails, parsed is None and you can inspect raw to see what the model actually returned.
Step 5: Use a JSON schema instead of Pydantic
As you progress through this langchain with_structured_output tutorial, you’ll notice dict schemas are interchangeable with Pydantic for the binding step. Some teams keep schemas as plain dicts.
json_schema = {
"title": "Contact",
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0},
"emails": {"type": "array", "items": {"type": "string", "format": "email"}}
},
"required": ["name", "age", "emails"]
}
structured_from_dict = llm.with_structured_output(json_schema)
print(structured_from_dict.invoke(text))
This yields the same object, but you lose Pydantic’s post-validation coercions (e.g., EmailStr checking). Use Pydantic unless you have a hard constraint.
Step 6: Handle multiple records
Models can return lists if you ask. Define a wrapper:
from typing import List
class ContactList(BaseModel):
contacts: List[Contact]
list_llm = llm.with_structured_output(ContactList)
multi = list_llm.invoke(
"Tom, 22, tom@x.com and Lucy, 41, lucy@y.com"
)
print(multi.contacts[1].name)
Expected: Lucy.
Step 7: Streaming—know the limitation
with_structured_output does not stream partial Pydantic objects. If you call .stream(), you get the full parsed object only after completion (or chunks of the raw string if include_raw=True with certain providers). For token-by-token UX, stream the base LLM and parse client-side, or accept that structured output is a batch operation.
# Does NOT yield incremental Contact objects
for chunk in structured_llm.stream(text):
print(chunk) # prints full Contact once at end
Step 8: Compose with a prompt template
You rarely call the model directly. Wrap with a ChatPromptTemplate:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "Extract contact info from the user text."),
("user", "{input}")
])
chain = prompt | structured_llm
out = chain.invoke({"input": text})
print(out.name)
This is the pattern you’ll actually deploy: prompt engineering on the left, typed parsing on the right.
Step 9: Validation and retries
If include_raw returns parsed=None, retry with a clearer instruction:
def extract(text):
res = structured_with_raw.invoke(text)
if res["parsed"] is None:
res = structured_with_raw.invoke(f"Strictly output JSON. {text}")
return res["parsed"]
print(extract("Bob, 50, bob@corp.net"))
For robust pipelines, use RunnableRetry from langchain_core, but the above shows the mechanics.
Common pitfalls
Pydantic v1 vs v2
LangChain >= 0.2 expects Pydantic v2. If you see TypeError: cannot use with_structured_output with pydantic v1, upgrade or use from pydantic.v1 import BaseModel only if the docs explicitly allow.
Temperature > 0
Structured output is deterministic by construction, but a high temperature can increase malformed JSON rates on fallback parsers. Keep temperature=0 for extraction.
Missing required fields
If the text lacks a field, the model may hallucinate. Constrain with Field(default=None) if optional, or catch ValidationError from include_raw.
Provider mismatch
Not every model behind an OpenAI-compatible endpoint supports response_format={"type":"json_object"}. Test before shipping.
Wrapping up
This langchain with_structured_output tutorial showed the shortest path from a prompt to a validated object: define schema, bind, invoke. The pattern replaces ad-hoc JSON parsing with a compile-time contract. For anything beyond toys, prefer Pydantic, keep temperature at zero, and wrap calls with include_raw=True so you can observe failures instead of swallowing them.