This article is published in English.
From Plain Text to Validated Objects: Picking a LangChain Output Parser
Compare StrOutputParser, JsonOutputParser, StructuredOutputParser and PydanticOutputParser in LangChain chains, and learn how much structure each one really guarantees.
A chat model hands back text, and text is fine for a person reading a screen. The moment a response has to feed another prompt, land in a database, or drive a UI component, you need something more predictable: a clean string, a JSON object, or a typed record that has already been checked. LangChain covers that gap with output parsers, and it ships several of them with very different guarantees. This walkthrough builds the same small Solar System project with four parsers, explains every line of each chain, and ends with a practical rule for choosing between them.
If you want the broader tour of how parsers sit alongside LCEL, runnables and memory, see from raw text to pipelines in LangChain. Here the focus is narrower: what each parser actually promises, and where that promise ends.
Why raw model text is not enough
Ask a model about the Solar System and you might get something like the following. It reads well, but a program cannot pull a specific fact out of it without guessing where one sentence ends and the next begins.
The Solar System consists of the Sun and the objects that orbit it.
It contains eight planets along with moons, asteroids, and comets.
Code that consumes this answer would much rather receive named values it can address directly, for example an object with one key per fact:
{
"fact_1": "The Solar System contains eight planets.",
"fact_2": "The Sun is at the center of the Solar System.",
"fact_3": "The Solar System also contains moons, asteroids, and comets."
}
An output parser is the component that bridges those two shapes. It receives whatever the model produced and returns a value your application can use without extra string surgery. Parsers differ in how far they go: some only unwrap the text, others parse JSON, and the strictest ones validate the result against a schema.
Raw LLM Response
↓
Output Parser
↓
Parsed Output
Keep that three-stage picture in mind. Every example below is some variation of it, with more machinery added to the middle box as the requirements tighten.
Four parsers, four levels of structure
The four parsers covered here form a ladder, each rung adding control over the result:
StrOutputParserturns the model's message into a plain Python string.JsonOutputParserparses the response into a JSON-compatible value such as a dict or list.StructuredOutputParserlets you declare the named fields the model should return.PydanticOutputParserdescribes the expected shape with a Pydantic model and validates the output against it.
Regardless of which one you pick, the data path looks the same:
LLM Response
↓
Output Parser
↓
Parsed Output
What changes is what comes out at the bottom, and how much you can trust it.
The running example: report, then summary
The first project is a two-step pipeline. A topic, "Solar System", goes to a model that writes a long report. That report is then fed into a second prompt that asks for a five-line summary. The key detail is the hand-off: the output of the first model call becomes the input of the next prompt.
Solar System
↓
LLM
↓
Detailed Report
↓
LLM
↓
5-Line Summary
Without a parser, the first step returns a message object, not the report text, so you would have to unwrap it before building the second prompt. A parser in the middle does that unwrapping as part of the chain, which is what makes the two steps compose cleanly.
StrOutputParser: when text is all you need
StrOutputParser is the simplest parser LangChain offers. Chat models return an AIMessage; this parser extracts its content and gives you an ordinary string. It is the right tool whenever the next consumer is a human, another prompt, or anything else that just wants prose, and you have no need for JSON or a schema.
Wiring it into the report pipeline
In the report-then-summary project, the steps are:
- The first prompt asks the model for a detailed report on the topic.
- That report is handed to the second prompt.
- The second prompt asks the model to condense the report into five lines.
A StrOutputParser sits after each model call, so every hand-off carries a plain string. The full sequence of components is:
Solar System
↓
Prompt 1
↓
LLM
↓
StrOutputParser
↓
Detailed Report
↓
Prompt 2
↓
LLM
↓
StrOutputParser
↓
5-Line Summary
The OpenAI version
Here is the complete chain using ChatOpenAI. Read it top to bottom once, then we will go through the parts that matter.
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
load_dotenv()
model = ChatOpenAI()
# 1st prompt -> detailed report
template1 = PromptTemplate(
template='Write a detailed report on {topic}',
input_variables=['topic']
)
# 2nd prompt -> summary
template2 = PromptTemplate(
template='Write a 5 line summary on the following text. /n {text}',
input_variables=['text']
)
parser = StrOutputParser()
chain = template1 | model | parser | template2 | model | parser
result = chain.invoke({'topic': 'Solar System'})
print(result)
The model is created with default settings. load_dotenv() above it pulls the API key from a local .env file, so no credentials live in the script.
model = ChatOpenAI()
The first template takes a single variable, topic, and asks for a detailed report on it.
template1 = PromptTemplate(
template='Write a detailed report on {topic}',
input_variables=['topic']
)
The second template expects a variable called text, which will receive the report, and asks for a five-line summary of it.
template2 = PromptTemplate(
template='Write a 5 line summary on the following text. /n {text}',
input_variables=['text']
)
One small bug is worth fixing if you copy this: the template string contains /n, which is a literal slash followed by the letter n, not a line break. Use \n if you want the report to start on a new line. Models usually cope either way, but the prompt you think you are sending should be the prompt you actually send.
Next comes the parser. A single instance can be reused at several points in the chain because it holds no state between calls.
parser = StrOutputParser()
The line that ties everything together is the chain definition:
chain = template1 | model | parser | template2 | model | parser
The pipe operator is LCEL (LangChain Expression Language) composition: each component's output becomes the next component's input. Laid out vertically, the order of execution is:
template1
↓
model
↓
parser
↓
template2
↓
model
↓
parser
Notice what the first parser buys you. After the first model call, the parser returns the report as a string, and that string is what fills {text} in the second template. The second parser does the same job for the final response, so the chain's result is the summary itself rather than a message object.
You start the chain by passing a dict whose keys match the first template's input variables:
result = chain.invoke({'topic': 'Solar System'})
Then you display the result:
print(result)
What the chain returns
The value printed at the end is the five-line summary derived from the generated report. Because the last component is a StrOutputParser, the result is a regular Python str:
print(result)
A typical run produces something along these lines:
1. The Solar System consists of the Sun and all objects that orbit it.
2. It includes eight planets, along with dwarf planets, moons, asteroids, and comets.
3. The four inner planets are rocky, while the outer planets are mostly gas or ice giants.
4. The Sun contains most of the Solar System's mass and provides the energy that drives many processes.
5. The Solar System is located in the Milky Way galaxy.
Treat that as an illustration of the shape, not a fixed answer. The wording will differ between runs and between models.
The same workflow with a Hugging Face model
The report-then-summary flow can also run against an open model. This variant uses HuggingFaceEndpoint pointed at google/gemma-2-2b-it and wraps it in ChatHuggingFace so it behaves like a chat model:
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
load_dotenv()
llm = HuggingFaceEndpoint(
repo_id="google/gemma-2-2b-it",
task="text-generation"
)
model = ChatHuggingFace(llm=llm)
# 1st prompt -> detailed report
template1 = PromptTemplate(
template='Write a detailed report on {topic}',
input_variables=['topic']
)
# 2nd prompt -> summary
template2 = PromptTemplate(
template='Write a 5 line summary on the following text. /n {text}',
input_variables=['text']
)
prompt1 = template1.invoke({'topic': 'Solar System'})
result = model.invoke(prompt1)
prompt2 = template2.invoke({'text': result.content})
result1 = model.invoke(prompt2)
print(result1.content)
There is an important difference here. This version never uses StrOutputParser and never builds a pipe chain. It formats each prompt by hand with .invoke(), calls the model, and reads .content off the returned message before passing it on. That works, but it is exactly the manual unwrapping the parser exists to remove.
Side by side, the two approaches look like this. With OpenAI and a parser:
OpenAI
Prompt
↓
ChatOpenAI
↓
StrOutputParser
↓
String
With Hugging Face and manual access:
Hugging Face
Prompt
↓
ChatHuggingFace
↓
result.content
↓
String
Both end in a string. The OpenAI script shows the parser doing that job inside a chain, while the Hugging Face script shows the same application logic with a different provider and no parser. Nothing stops you from writing template1 | model | parser | template2 | model | parser with the Hugging Face model as well; the parser does not care which provider produced the message.
The takeaway for this rung: reach for StrOutputParser whenever the application only needs the response as text.
JsonOutputParser: JSON without a contract
JsonOutputParser is the next step up. It asks the model for JSON and turns the reply into Python data, which is handy when your code needs to pick values out by key instead of reading prose.
What it does not do is enforce a particular shape. Without a schema, it tells the model to answer in JSON but says nothing about which keys must appear or what types their values should have. Two runs with the same prompt can legitimately return differently shaped objects, and your code has to be ready for that.
How the pieces fit
This project asks the model for five facts about the Solar System. The steps are:
- Create a
JsonOutputParser. - Ask it for format instructions with
get_format_instructions(). - Insert those instructions into the prompt.
- Send the prompt to the model.
- Let the parser turn the reply into a Python value.
Solar System
↓
PromptTemplate
↓
Format Instructions
↓
LLM
↓
JsonOutputParser
↓
JSON Object
The OpenAI version
The complete chain is short:
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import JsonOutputParser
load_dotenv()
# Define Model
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
parser = JsonOutputParser()
template = PromptTemplate(
template="Give me 5 facts about {topic} \n {format_instruction}",
input_variables=["topic"],
partial_variables={"format_instruction": parser.get_format_instructions()},
)
chain = template | model | parser
result = chain.invoke({"topic": "Solar System"})
print(result)
The model is configured with gpt-4.1-mini and a temperature of 0, which keeps the output as repeatable as the model allows. It is the component that will write the five facts.
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
The parser is created with no arguments, which is precisely why it has no schema to enforce:
parser = JsonOutputParser()
Format instructions do the real work
Before the model runs, the parser can describe the format it expects. That description comes from one method call:
parser.get_format_instructions()
It returns a block of text that tells the model to reply with JSON. It is not code that runs against the model; it is prompt text. You inject it into the template through partial_variables, which fills a template variable once, when the template is defined, rather than on every call:
partial_variables={
"format_instruction": parser.get_format_instructions()
}
The resulting template carries two placeholders:
template = PromptTemplate(
template="Give me 5 facts about {topic} \n {format_instruction}",
input_variables=["topic"],
partial_variables={
"format_instruction": parser.get_format_instructions()
},
)
{topic}is supplied at call time and names what you want facts about.{format_instruction}is pre-filled with the parser's formatting guidance.
So when you invoke the chain with this input, the model receives both the topic and the instruction to answer in JSON:
{"topic": "Solar System"}
Assembling and running the chain
The chain itself has only three stages:
chain = template | model | parser
In execution order:
PromptTemplate
↓
ChatOpenAI
↓
JsonOutputParser
↓
Parsed JSON
The template renders the final prompt, ChatOpenAI answers it, and JsonOutputParser parses the reply into Python data. The parser is also forgiving about some common model habits, such as wrapping the JSON in a Markdown code fence, which it strips before parsing.
Invoke it with the topic:
result = chain.invoke({"topic": "Solar System"})
And print what came back:
print(result)
What comes back
The result holds five facts in JSON form. The script only prints the value, so there is no canonical output to quote; a plausible response looks like this:
{
"facts": [
"The Solar System is centered around the Sun.",
"There are eight recognized planets in the Solar System.",
"The four inner planets are rocky planets.",
"The outer planets include gas giants and ice giants.",
"The Solar System is located in the Milky Way galaxy."
]
}
That shape, a single facts key holding a list, is one of several the model might choose. Another run could return fact_1 through fact_5, or a bare list. If downstream code indexes into result["facts"], it will break the day the model picks a different layout.
Tracing the flow
Compared with StrOutputParser, the new ingredient is that the parser participates twice: once before the model call, by contributing instructions, and once after, by parsing.
Prompt
↓
JSON Format Instructions
↓
LLM
↓
JsonOutputParser
↓
JSON
The instructions are produced by the parser itself:
parser.get_format_instructions()
They reach the prompt through the partial variable:
partial_variables={
"format_instruction": parser.get_format_instructions()
}
The model answers with those instructions in view, and the parser converts the answer into a Python value. End to end:
Solar System
↓
PromptTemplate
↓
JSON Format Instructions
↓
ChatOpenAI
↓
JsonOutputParser
↓
JSON Object
The contrast with the previous rung fits in two lines. StrOutputParser produces:
StrOutputParser
↓
Plain String
while JsonOutputParser produces:
JsonOutputParser
↓
JSON-compatible Structured Data
Keep the caveat in view: there is still no fixed schema. You get JSON, but the fields and nesting are up to the model. As a side note, current versions of JsonOutputParser also accept an optional pydantic_object argument that adds a schema to the format instructions, but in the form shown here, with no arguments, it only asks for valid JSON.
Hugging Face variant
The JSON workflow ports directly to the Gemma model. The model setup changes; the parser, the format instructions and the chain do not:
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import JsonOutputParser
load_dotenv()
# Define the model
llm = HuggingFaceEndpoint(
repo_id="google/gemma-2-2b-it",
task="text-generation"
)
model = ChatHuggingFace(llm=llm)
parser = JsonOutputParser()
template = PromptTemplate(
template='Give me 5 facts about {topic} \n {format_instruction}',
input_variables=['topic'],
partial_variables={
'format_instruction': parser.get_format_instructions()
}
)
chain = template | model | parser
result = chain.invoke({'topic': 'Solar System'})
print(result)
The pipeline is identical apart from the model box:
PromptTemplate
↓
Hugging Face Model
↓
JsonOutputParser
↓
JSON Object
This is the practical benefit of putting parsing in its own component: swapping providers does not touch the parsing logic. Be aware, though, that small open models follow format instructions less reliably than larger hosted ones. If the reply contains prose around the JSON or a trailing comma, the parser raises an OutputParserException, so production code should catch it and retry or fall back.
StructuredOutputParser: naming the fields you expect
StructuredOutputParser extracts JSON based on a list of fields you define up front. Where JsonOutputParser only says "answer in JSON", this parser says "answer in JSON with these keys".
Fields are declared with ResponseSchema. Each one has a name and a description, and the description tells the model what belongs in that field. The result is noticeably more control over the shape of the response.
The three-fact project
This project asks for three facts about the Solar System, with one field per fact:
fact_1holds the first fact about the topic.fact_2holds the second.fact_3holds the third.
The parser turns those declarations into instructions and then reads the response back against them:
Solar System
↓
PromptTemplate
↓
Predefined Field Schema
↓
LLM
↓
StructuredOutputParser
↓
Structured JSON
The OpenAI version
Here is the full script:
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain.output_parsers import StructuredOutputParser, ResponseSchema
load_dotenv()
# Define Model
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
schema = [
ResponseSchema(name="fact_1", description="Fact 1 about the topic"),
ResponseSchema(name="fact_2", description="Fact 2 about the topic"),
ResponseSchema(name="fact_3", description="Fact 3 about the topic"),
]
parser = StructuredOutputParser.from_response_schemas(schema)
template = PromptTemplate(
template="Give 3 fact about {topic} \n {format_instruction}",
input_variables=["topic"],
partial_variables={"format_instruction": parser.get_format_instructions()},
)
chain = template | model | parser
result = chain.invoke({"topic": "Solar System"})
print(result)
The model is the same gpt-4.1-mini configuration as before:
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
The real difference starts with the schema list:
schema = [
ResponseSchema(name="fact_1", description="Fact 1 about the topic"),
ResponseSchema(name="fact_2", description="Fact 2 about the topic"),
ResponseSchema(name="fact_3", description="Fact 3 about the topic"),
]
Each ResponseSchema contributes two things:
namebecomes the key in the resulting dict.descriptiontells the model what that key should contain.
Three schemas give you three required keys: fact_1, fact_2 and fact_3.
You do not instantiate this parser directly. A class method builds it from the schema list:
parser = StructuredOutputParser.from_response_schemas(schema)
As with the JSON parser, the format instructions come from the parser:
parser.get_format_instructions()
This time the instructions are richer. They include a JSON skeleton listing every field name with its description, and they ask the model to wrap the answer in a fenced json block. They are wired in the same way:
partial_variables={
"format_instruction": parser.get_format_instructions()
}
The prompt template has the familiar two placeholders:
template = PromptTemplate(
template="Give 3 fact about {topic} \n {format_instruction}",
input_variables=["topic"],
partial_variables={
"format_instruction": parser.get_format_instructions()
},
)
{topic} is filled at call time, and {format_instruction} carries the field list generated from your schemas. Invoking with this input sends both to the model:
{"topic": "Solar System"}
Running the chain
The chain has the same three stages as before:
chain = template | model | parser
With the parser in the last position:
PromptTemplate
↓
ChatOpenAI
↓
StructuredOutputParser
↓
Structured JSON
The template builds the prompt, the model answers, and StructuredOutputParser extracts the declared fields from the reply.
result = chain.invoke({"topic": "Solar System"})
print(result)
What the output looks like
The result is a dict holding three facts under the keys you defined. The script prints it:
print(result)
and a representative result is:
{
"fact_1": "The Solar System is centered around the Sun.",
"fact_2": "There are eight recognized planets in the Solar System.",
"fact_3": "The Solar System is located in the Milky Way galaxy."
}
The specific facts will vary. What should not vary is the set of keys:
fact_1
fact_2
fact_3
That is the gain over plain JSON parsing: your application decides the field names, not the model. If the model omits one of the declared keys, the parser raises an error instead of silently returning a different shape, which is far easier to handle than a KeyError three functions later.
Tracing the flow
The full path, from topic to keyed result:
Solar System
↓
PromptTemplate
↓
ResponseSchema
↓
Format Instructions
↓
ChatOpenAI
↓
StructuredOutputParser
↓
{
fact_1: ...,
fact_2: ...,
fact_3: ...
}
It starts with the field definitions:
ResponseSchema(
name="fact_1",
description="Fact 1 about the topic"
)
The parser turns those into format instructions, the instructions go into the prompt, the model responds, and the parser pulls out the declared fields. Compared with the previous rung:
JsonOutputParser
↓
JSON output
↓
Structure can vary
StructuredOutputParser
↓
Predefined fields
↓
More controlled structure
In short, JsonOutputParser is about getting JSON at all, while StructuredOutputParser is about getting JSON with the keys you asked for.
There is a limit worth stating plainly. ResponseSchema has a type attribute that defaults to string, but it only changes the wording of the instructions. The parser checks that the keys are present; it does not validate value types or ranges. If you need age to be an integer above some threshold, this parser will not enforce it.
Also check the import path against the LangChain version you are running. The example imports from langchain.output_parsers, and in newer releases this legacy parser has moved out of the core packages, so the import may need to change.
Hugging Face variant
The Gemma version reuses the same three schemas and the same parser construction:
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain.output_parsers import StructuredOutputParser, ResponseSchema
load_dotenv()
# Define the model
llm = HuggingFaceEndpoint(
repo_id="google/gemma-2-2b-it",
task="text-generation"
)
model = ChatHuggingFace(llm=llm)
schema = [
ResponseSchema(name='fact_1', description='Fact 1 about the topic'),
ResponseSchema(name='fact_2', description='Fact 2 about the topic'),
ResponseSchema(name='fact_3', description='Fact 3 about the topic'),
]
parser = StructuredOutputParser.from_response_schemas(schema)
template = PromptTemplate(
template='Give 3 fact about {topic} \n {format_instruction}',
input_variables=['topic'],
partial_variables={
'format_instruction': parser.get_format_instructions()
}
)
chain = template | model | parser
result = chain.invoke({'topic': 'Solar System'})
print(result)
And the same pipeline:
PromptTemplate
↓
ChatHuggingFace
↓
StructuredOutputParser
↓
Structured JSON
Only the model provider differs. The schema and the parser stay the same.
PydanticOutputParser: structure plus validation
PydanticOutputParser is the strictest of the four. It describes the expected response with a Pydantic model, so the definition of the output is also the definition of what counts as valid.
That goes beyond parsing. Fields carry real Python types, and Field() can attach constraints, for example that an integer must exceed a minimum. When the model's reply does not satisfy them, you get an exception rather than bad data.
Why it is worth the extra setup
- Schema enforcement: the response must match a well-defined shape.
- Type safety: fields use Python types such as
str,intandfloat, and values are coerced or rejected accordingly. - Validation: Pydantic checks every constraint you declare.
- Chain integration: it plugs into prompts, models and LCEL chains exactly like the other parsers.
The fictional-person project
This example asks the model to invent a person from a given place, here "Indian", with three fields:
name, the person's name.age, the person's age.city, the city they live in.
The age field also gets a constraint: it must be greater than 18.
Input
↓
PromptTemplate
↓
Pydantic Model
↓
Format Instructions
↓
LLM
↓
PydanticOutputParser
↓
Validated Pydantic Object
The OpenAI version
The complete script:
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field
load_dotenv()
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
class Person(BaseModel):
name: str = Field(description="Name of the person")
age: int = Field(gt=18, description="Age of the person")
city: str = Field(description="Name of the city of the person")
parser = PydanticOutputParser(pydantic_object=Person)
template = PromptTemplate(
template='Generate the name, age and city of a fictional {place} person \n {format_instruction}',
input_variables=["place"],
partial_variables={"format_instruction": parser.get_format_instructions()},
)
chain = template | model | parser
final_result = chain.invoke({"place": "Indian"})
print(final_result)
It follows the same outline as before: define a Person model, hand it to PydanticOutputParser, and connect the parser to a prompt and a model.
The model configuration is unchanged:
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
The centrepiece is the Pydantic class:
class Person(BaseModel):
name: str = Field(description="Name of the person")
age: int = Field(gt=18, description="Age of the person")
city: str = Field(description="Name of the city of the person")
This is the contract for the response:
namemust be a string.agemust be an integer strictly greater than18.citymust be a string.
Field() attaches both a human-readable description, which ends up in the prompt, and constraints, which are checked after parsing. The constrained field on its own:
age: int = Field(gt=18, description="Age of the person")
gt=18 means "greater than 18", so an age of exactly 18 fails validation. If you meant "18 or older", use ge=18 instead.
The parser is created by passing the class, not an instance:
parser = PydanticOutputParser(pydantic_object=Person)
That tells it which model to use both for generating instructions and for validating the reply.
The format instructions come from the same method as before:
parser.get_format_instructions()
For this parser they contain a JSON Schema generated from the Pydantic model, including field descriptions and the exclusiveMinimum constraint on age. They are inserted through the partial variable as usual:
partial_variables={
"format_instruction": parser.get_format_instructions()
}
The prompt template:
template = PromptTemplate(
template='Generate the name, age and city of a fictional {place} person \n {format_instruction}',
input_variables=["place"],
partial_variables={"format_instruction": parser.get_format_instructions()},
)
{place} controls what kind of person the model should invent. Invoking with this input asks for the name, age and city of a fictional Indian person:
{"place": "Indian"}
Running the chain
The chain keeps the familiar three-stage shape:
chain = template | model | parser
This time the final stage returns a model instance:
PromptTemplate
↓
ChatOpenAI
↓
PydanticOutputParser
↓
Pydantic Object
The template builds the prompt, the model answers, and PydanticOutputParser parses the JSON and validates it into a Person.
final_result = chain.invoke({"place": "Indian"})
print(final_result)
What the output looks like
The result is a Person object, not a dict. Printing it shows Pydantic's default representation:
name='Rahul Sharma' age=28 city='Mumbai'
The values will differ from run to run. The guarantees will not:
name → string
age → integer (> 18)
city → string
This is where the rungs separate most clearly. JsonOutputParser only asked for JSON, StructuredOutputParser named the fields, and PydanticOutputParser represents the whole contract as a real class. You can access final_result.age with editor autocompletion and be sure it is an int above 18, because anything else would have raised a validation error before reaching your code.
Tracing the flow
From input to validated object:
"Indian"
↓
PromptTemplate
↓
Pydantic Model
↓
Format Instructions
↓
ChatOpenAI
↓
PydanticOutputParser
↓
Person Object
It begins with the structure, shown here without the descriptions and constraints for readability:
class Person(BaseModel):
name: str
age: int
city: str
The class is passed to the parser:
PydanticOutputParser(pydantic_object=Person)
The parser generates instructions from the model, the instructions are included in the prompt, the model responds, and the parser parses the reply into Person and runs Pydantic validation on it. Conceptually:
Pydantic Model
↓
Defines Structure + Types + Constraints
↓
LLM Response
↓
PydanticOutputParser
↓
Validated Pydantic Object
You end up with a Python object whose data is guaranteed to follow your rules, which is more than any JSON dict can promise.
One practical consequence: a validation failure surfaces as an OutputParserException from the chain. Decide what should happen then. Common options are retrying the call, feeding the error back to the model with LangChain's OutputFixingParser, or logging and returning a safe default.
Hugging Face variant
The Gemma version defines the same Person model and passes it to the same parser:
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field
load_dotenv()
llm = HuggingFaceEndpoint(
repo_id="google/gemma-2-2b-it",
task="text-generation"
)
model = ChatHuggingFace(llm=llm)
class Person(BaseModel):
name: str = Field(description='Name of the person')
age: int = Field(gt=18, description='Age of the person')
city: str = Field(description='Name of the city the person belongs to')
parser = PydanticOutputParser(pydantic_object=Person)
template = PromptTemplate(
template='Generate the name, age and city of a fictional {place} person \n {format_instruction}',
input_variables=['place'],
partial_variables={
'format_instruction': parser.get_format_instructions()
}
)
chain = template | model | parser
final_result = chain.invoke({'place': 'Indian'})
print(final_result)
The pipeline is unchanged:
PromptTemplate
↓
ChatHuggingFace
↓
PydanticOutputParser
↓
Pydantic Object
Only the provider changes; the Pydantic model and the parser are shared. Smaller models are more likely to violate constraints or add stray text, which is exactly the case where validation earns its keep: the bad response is caught at the boundary instead of leaking into your data.
Choosing the right parser
The decision comes down to how much structure and how much validation the consumer of the response really needs. Here is each option summarised.
StrOutputParser
Use it when the model's answer is simply text.
- Best for: reports, explanations, summaries, chat replies.
- Returns: a string.
- JSON parsing: no.
- Schema: no.
- Validation: no.
JsonOutputParser
Use it when you need JSON but can tolerate, or handle, a variable shape.
- Best for: exploratory structured output, flexible payloads.
- Returns: a dict or list.
- JSON parsing: yes.
- Schema: no (in the no-argument form used here).
- Validation: no beyond "is valid JSON".
StructuredOutputParser
Use it when your code expects specific keys, such as fact_1 to fact_3.
- Best for: simple records with known field names.
- Returns: a dict with the declared keys.
- JSON parsing: yes.
- Schema: yes, field names and descriptions.
- Validation: key presence only, no type checks.
PydanticOutputParser
Use it when the output feeds application logic directly and must be correct.
- Best for: data you store, compute with, or pass to APIs.
- Returns: an instance of your Pydantic model.
- JSON parsing: yes.
- Schema: yes, full types and constraints.
- Validation: yes.
A quick mental model
StrOutputParser: text is enough.JsonOutputParser: any valid JSON will do.StructuredOutputParser: the JSON must contain these keys.PydanticOutputParser: these keys, these types, and every constraint checked.
Pick the simplest parser that delivers the guarantees you depend on. Each rung adds prompt tokens for the instructions and new ways for a response to be rejected, so extra strictness should be a deliberate choice.
One more option belongs in the picture. All four parsers work by describing a format in the prompt and parsing text afterwards. Many chat models also support native structured output or tool calling, which LangChain exposes through with_structured_output() on the model. When your provider supports it, that approach is usually more reliable for schema-shaped data, while prompt-based parsers remain useful for providers and models that lack it.
Key takeaways
- Output parsers convert a model's message into a value your code can use, and they slot into LCEL chains with the pipe operator.
StrOutputParserremoves the message wrapper so one model's text can feed the next prompt.JsonOutputParserparses JSON but does not fix its shape unless you give it a schema.StructuredOutputParserfixes the key names throughResponseSchema, yet leaves value types unchecked.PydanticOutputParsercombines parsing with type checks and constraints, returning a real object.- Format instructions are just prompt text: the model can still ignore them, so plan for parse and validation errors, especially with smaller open models.
- Switching providers leaves the parser untouched, which makes it easy to compare hosted and open models on the same task.
Once responses come back in a dependable shape, the natural next step is composing several prompts, models and parsers into larger workflows, including sequential, parallel and conditional chains built with RunnableParallel and RunnableBranch. For that side of the story, see composing LangChain pipelines with LCEL.