Skip to content

Structured Output: with_structured_output and When You Still Need a Parser

Site Console Site Console
16 min read Updated Oct 13, 2026 AI & Tools 0 comments

Prose Is a Dead End

The classifier from post 3 needs to write two values into a database: a topic label and an urgency score. What the model gives you is this:

This looks like a billing issue, and given that the customer mentions being charged twice I'd treat it as fairly urgent — maybe a 4 out of 5.

Everything you need is in there. Extracting it with string operations is also the single most reliable way to generate a production incident, because the model will phrase it differently tomorrow, and "maybe a 4 out of 5" will become "high urgency" the first time someone edits the system prompt.

The fix is not better parsing. It is to stop asking for prose.

Everything below was executed on 12 October 2026 against langchain 1.4.3, langchain-core 1.6.9, langchain-classic 1.0.8 and pydantic 2.14.0. Every error message and format-instruction string is real output.


The Primary Path

Define the shape you want with Pydantic, then bind it to the model:

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

class Ticket(BaseModel):
    """A classified support ticket."""
    label: Literal["billing", "bug", "feature_request", "other"] = Field(
        description="Topic of the ticket"
    )
    urgency: int = Field(description="Urgency from 1 (low) to 5 (critical)", ge=1, le=5)
    summary: str = Field(description="One-sentence summary")

model = init_chat_model("gpt-5.6-luna")
classifier = model.with_structured_output(Ticket)

ticket = classifier.invoke("I was charged twice for my subscription this month.")
print(ticket.label, ticket.urgency)
# billing 4

What comes back is a Ticket instance. Not a string that looks like JSON, not a dict you hope has the right keys — a validated object, with ge=1, le=5 enforced and label constrained to four values.

The description on each field is not documentation. It is sent to the model as part of the schema, and it is how the model knows what "urgency" means on your scale rather than its own. Fields without descriptions are the most common reason structured output comes back technically valid and semantically wrong.

with_structured_output also returns a Runnable, which is the thing post 7 is about — it drops into a pipeline next to a prompt and a model without any of them knowing about the others.


What Validates and What Does Not

The method accepts four kinds of schema: a Pydantic class, a TypedDict, a raw JSON Schema, or an OpenAI function/tool schema. The difference between them is not style.

A Pydantic class gives you an instance of that class, with the model-generated fields validated by Pydantic. Everything else gives you a plain dict, and — in the library's own words — it "will not be validated".

That distinction is worth sitting with. With a TypedDict, your editor believes urgency is an int and your runtime will happily hand you the string "high" if the model produces one, because TypedDict is a type-checker fiction with no runtime enforcement. Use it when you want the shape documented and are doing your own checking downstream. Use Pydantic when you want the guarantee.


When You Also Need the Raw Response

By default you get the parsed object and nothing else — which means no token counts, and a parse failure raises. Pass include_raw=True and both of those change:

classifier = model.with_structured_output(Ticket, include_raw=True)
result = classifier.invoke("I was charged twice for my subscription this month.")

result["parsed"]         # the Ticket instance, or None if parsing failed
result["raw"]            # the AIMessage — usage_metadata lives here
result["parsing_error"]  # the exception, or None

The output is always a dict with exactly those three keys. The behavioural change matters as much as the extra data: with include_raw=True, a parsing error is caught and handed to you in parsing_error rather than raised. That turns a crash into a branch, which is usually what you want in a batch job processing ten thousand tickets — log the three that failed, keep going.

It is also where the cost numbers from post 3 live. result["raw"].usage_metadata is how you find out that your structured call spent 256 reasoning tokens.


Output Parsers, Honestly

Most LangChain material you will find teaches structured output the other way round, with PydanticOutputParser. It is worth understanding why that approach came first and why it is no longer the default.

The parsers solve the problem from the outside: they ask the model nicely, in the prompt, to emit a particular format, then try to parse whatever comes back. Here is what "ask nicely" costs. PydanticOutputParser(pydantic_object=Ticket).get_format_instructions() produces 929 characters that you must paste into your prompt, beginning:

The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {"properties": {"foo": {"title": "Foo", ...

Nine hundred characters of boilerplate on every call, to request something the provider can enforce for free. On a tool-calling model, with_structured_output constrains generation at the API level; the parser writes an instruction and hopes.

Two cases still favour a parser on the JSON path.

JsonOutputParser parses incomplete JSON. This is a genuine capability nothing else gives you, and it is the reason to reach for it while streaming:

parser = JsonOutputParser()
parser.parse('{"label": "bil')
# {'label': 'bil'}
parser.parse('{"label": "billing", "urgency": 4')
# {'label': 'billing', 'urgency': 4}

Mid-stream, with half the object on the wire, you still get a usable dict — which is how you render a structured response progressively instead of staring at a spinner until the closing brace arrives.

PydanticOutputParser covers models without native support. with_structured_output raises NotImplementedError on a model that does not implement it, and not every provider or local model does. When the schema cannot be enforced at the API level, asking in the prompt is the only option left — and then the 929 characters are a reasonable price.

Beyond JSON there is a second family of parsers that exists for a different reason entirely: the output is not an object at all, it is a scalar. Those are the next four sections.


How Parsing Actually Fails

Three real failures, from running PydanticOutputParser against three payloads:

urgency out of range  -> OutputParserException: Failed to parse Ticket from completion
                         {"label": "billing", "urgency": 9, ...}. Got: 1 validation error
prose wrapper         -> OutputParserException: Invalid json output:
                         Sure! Here is the JSON: {"label": "bug", "urgency": 2, ...}
label not in Literal  -> OutputParserException: Failed to parse Ticket from completion
                         {"label": "nonsense", "urgency": 2, ...}. Got: 1 validation error

The middle one is the failure you will actually meet. The model produced perfectly good JSON and prefixed it with "Sure! Here is the JSON:" — because you asked for JSON in a prompt, and prompts are requests rather than constraints. No amount of emphasis in the instruction reliably prevents it. Schema-enforced generation does.


String Output Parser

StrOutputParser is the parser you will use most and think about least. It takes whatever a model returned and gives you the string:

from langchain_core.output_parsers import StrOutputParser
from langchain.messages import AIMessage, HumanMessage

parser = StrOutputParser()
parser.invoke(AIMessage("Just the text, please."))
# 'Just the text, please.'

It is the only parser that adds nothing to your prompt. get_format_instructions() on it raises NotImplementedError, which is the correct answer — there is no format to request, because it is not asking the model for anything. It costs zero tokens.

It is also idempotent and indifferent to what it receives:

parser.invoke("already a string")          # 'already a string'
parser.invoke(HumanMessage("from a human")) # 'from a human'

That tolerance is why it drops anywhere in a pipeline without breaking: a chain ending in a model emits an AIMessage, the thing after it usually wants text, and StrOutputParser is the adapter. Post 7 has one in almost every example.

Two behaviours are worth knowing before they surprise you, because neither raises. When message content is a list of blocks rather than a string, the parser concatenates the text blocks with no separator:

AIMessage(content=[{"type": "text", "text": "part one"},
                   {"type": "text", "text": "part two"}])
# StrOutputParser -> 'part onepart two'

And non-text blocks are dropped silently:

AIMessage(content=[{"type": "text", "text": "caption"},
                   {"type": "image_url", "image_url": {"url": "http://x/y.png"}}])
# StrOutputParser -> 'caption'

Neither is wrong — .text on a message behaves the same way, and a string parser has nothing useful to do with an image. But if you are working with reasoning traces, citations or multimodal responses, the string view is lossy by design, and content_blocks from post 5 is where the rest of the response went.


Comma-Separated List Output Parser

When you want a list of short strings — tags, keywords, candidate labels, ingredient names — CommaSeparatedListOutputParser is the whole solution, and its format instructions are 94 characters rather than 929:

from langchain_core.output_parsers import CommaSeparatedListOutputParser

parser = CommaSeparatedListOutputParser()
print(parser.get_format_instructions())
# Your response should be a list of comma separated values,
# eg: `foo, bar, baz` or `foo,bar,baz`

parser.parse("billing, bug, feature request")
# ['billing', 'bug', 'feature request']

OutputType is list[str], and the clean case is genuinely clean. The important thing about this parser, however, is that it never raises. Under the hood it is csv.reader with delimiter=",", quotechar='"' and skipinitialspace=True, which means every input produces a list of something. Measured:

Input

Result

"billing,bug,feature request"

['billing', 'bug', 'feature request']

"billing\nbug\nfeature request"

['billing', 'bug', 'feature request']

'"billing", "bug"'

['billing', 'bug']

" billing , bug "

['billing ', 'bug ']

"billing, bug, and feature request"

['billing', 'bug', 'and feature request']

"Here are the labels: billing, bug"

['Here are the labels: billing', 'bug']

"billing, bug,"

['billing', 'bug', '']

""

[]

Read the bottom half of that table, because it is the failure mode. skipinitialspace=True strips leading whitespace and not trailing, so "billing " survives with a space on the end — enough to break a dictionary lookup or a set membership test while looking correct in a log. A conversational prefix is absorbed into the first element rather than rejected. An Oxford "and" becomes part of the last element. A trailing comma yields an empty string.

Because csv.reader treats newlines as row boundaries and the parser flattens the rows, a model that answers with a bulleted list on separate lines will also parse — which sounds forgiving until you notice the hyphens come through as part of the values.

So this parser is the right tool when the values are unconstrained and you will validate them yourself, and the wrong tool when they are not. If the list must come from a fixed vocabulary, with_structured_output with list[Literal[...]] enforces that at the API level and fails loudly instead of handing you 'Here are the labels: billing'. The rule of thumb: use the list parser for open-ended extraction, use a schema for closed sets. And strip what you get back regardless.


A Note on Using langchain-classic

The next parser does not live in core, which makes this the right moment for the practical rules on langchain-classic — because the choice comes up repeatedly from here on.

Post 2 covered what the v1 split moved. What matters when you actually reach for it is narrower, and three things are worth stating plainly.

It is not installed with langchain. It is not a dependency; langchain requires only langchain-core, langgraph and pydantic. You install classic on purpose:

pip install langchain-classic

It is not deprecated, and importing from it warns about nothing. I checked, because the name invites the assumption:

from langchain_classic.chains import LLMChain
from langchain_classic.output_parsers import DatetimeOutputParser, OutputFixingParser
# zero warnings raised

"Classic" marks where v1 moved its older surface area, not a countdown. Code that imports from it is supported code, and the distinction matters when you are deciding whether a one-line import is technical debt. For a parser like the one below, it is not.

It is heavier than core. Classic brings sqlalchemy, requests, pyyaml and langsmith as hard dependencies. On a laptop that is invisible. In a Lambda deployment package or a slim container image it is not, and that — rather than deprecation — is the real argument for preferring a core equivalent when one exists.

Which brings up the question you should ask each time: is the thing I want in core under a different name? Several parsers exist in both packages, and in those cases core is the one to import. CommaSeparatedListOutputParser above is in both. PydanticOutputParser, XMLOutputParser, NumberedListOutputParser and MarkdownListOutputParser are in both. Classic-only, among the parsers, are DatetimeOutputParser, BooleanOutputParser, EnumOutputParser, StructuredOutputParser, YamlOutputParser, PandasDataFrameOutputParser, the regex parsers, and the two repair parsers — OutputFixingParser and RetryOutputParser, which feed broken output back to the model and ask it to fix itself.

That last pair is the clearest statement about where v1 thinks the problem belongs. Both still work. Both now sit behind a deliberate install, one package away from the default path, because the v1 answer to malformed output is to not generate it rather than to repair it afterwards.


Datetime Output Parser

Dates are the case where a scalar parser earns its place, because a date is not an object and datetime is not a JSON type. DatetimeOutputParser asks the model for a timestamp in a strftime pattern and hands back a real datetime:

from langchain_classic.output_parsers import DatetimeOutputParser

parser = DatetimeOutputParser()
print(parser.format)
# '%Y-%m-%dT%H:%M:%S.%fZ'

print(parser.get_format_instructions())
# Write a datetime string that matches the following pattern: '%Y-%m-%dT%H:%M:%S.%fZ'.
#
# Examples: 2023-07-04T14:30:00.000000Z, 1999-12-31T23:59:59.999999Z, 2025-01-01T00:00:00.000000Z
#
# Return ONLY this string, no other words!

parser.parse("2026-10-12T14:01:00.000000Z")
# datetime.datetime(2026, 10, 12, 14, 1)

Those instructions are 223 characters, the examples are generated from the pattern rather than hardcoded, and OutputType is datetime.datetime. A custom pattern is a constructor argument and the instructions follow it:

DatetimeOutputParser(format="%Y-%m-%d").get_format_instructions()
# Write a datetime string that matches the following pattern: '%Y-%m-%d'.
# Examples: 2026-10-12, 2025-10-12, 2026-10-11 ...

Now the two things that will bite you, both verified.

The Z is a literal, and what you get back is naive. In %Y-%m-%dT%H:%M:%S.%fZ the trailing Z is just a character strptime matches and discards. It does not set a timezone. So the parser instructs the model to write UTC, and returns a datetime with tzinfo=None:

import datetime

d = parser.parse("2026-10-12T14:01:00.000000Z")
d.tzinfo                                            # None
d > datetime.datetime.now(datetime.timezone.utc)
# TypeError: can't compare offset-naive and offset-aware datetimes

That TypeError is the whole problem in one line. The value is semantically UTC and structurally naive, and the mismatch surfaces at the first comparison, which may be a long way from the parse. Attach the timezone yourself at the boundary:

d = parser.parse(text).replace(tzinfo=datetime.timezone.utc)

It is strict, and strictness is a reliability cost. The fractional-seconds field is mandatory. Any number of digits works, but omitting the fraction entirely fails:

'2026-10-12T14:01:00.000000Z'  OK
'2026-10-12T14:01:00.000Z'     OK
'2026-10-12T14:01:00.0Z'       OK
'2026-10-12T14:01:00Z'         OutputParserException: Could not parse datetime string
'2026-10-12 14:01:00'          OutputParserException
'2026-10-12'                   OutputParserException
'12 October 2026'              OutputParserException

Surrounding whitespace is stripped, so a padded answer is fine. A conversational one is not — "The answer is 2026-10-12T14:01:00.000000Z" raises, which is the same prose-wrapper failure from earlier in this post, in a parser that cannot tolerate it at all.

"2026-10-12T14:01:00Z" is a completely reasonable thing for a model to emit and it fails. So if you use this parser, either narrow the format to something the model will hit reliably, or catch OutputParserException and retry. And consider the alternative first: a Pydantic field typed datetime under with_structured_output gets you schema-enforced generation, Pydantic's considerably more permissive ISO-8601 parsing, and timezone handling that works — which is why this parser is best understood as the fallback for models that cannot do structured output, not the default way to ask for a date.


Choosing

Reach for with_structured_output with a Pydantic schema by default. It is fewer lines, it costs no prompt tokens, it validates at runtime, and it eliminates the entire class of prose-wrapper failure above. Add include_raw=True when you need token counts or would rather branch than crash.

For the parsers, the question is what shape you need:

You want

Use

Lives in

A validated object

with_structured_output + Pydantic

—

The text out of a message

StrOutputParser

core

Partial objects while streaming

JsonOutputParser

core

A list of open-ended strings

CommaSeparatedListOutputParser

core

A list from a fixed vocabulary

with_structured_output + Literal

—

A datetime

Pydantic field, or DatetimeOutputParser

classic

An object from a model without tool calling

PydanticOutputParser

core

The pattern across all of them: a parser reads what arrived, a schema constrains what gets generated. Prefer constraining. Parse when you cannot — and when you do parse, assume the output needs stripping, timezone-attaching and validating, because none of these parsers will do it for you and only one of them will tell you.

Starting with the next post you will be pulling text out of messages constantly, which makes StrOutputParser the one from this list you will actually type every day.


🧭 What's Next

  • Post 7: LCEL — you now have a prompt that takes variables, a model that returns typed objects, and a parser that extracts text. The next post connects them with a pipe operator into a single object that streams, batches and runs async without you writing any of it.

Related

Leave a comment

Sign in to leave a comment.

Comments