This langchain function calling structured extraction tutorial walks through extracting typed records from raw text using LangChain’s with_structured_output and Pydantic. You’ll build a small pipeline that parses emails, support tickets, or job posts into validated Python objects without hand-written regex.
Prerequisites
- Python 3.10 or newer
langchain,langchain-openai,pydanticv2,python-dotenv- An OpenAI-compatible API key (OpenAI, or a gateway that exposes the same surface)
pip install langchain langchain-openai pydantic python-dotenv
Create a .env with your key:
OPENAI_API_KEY=sk-...
# or for a gateway:
# OPENAI_API_KEY=your-gateway-key
# OPENAI_BASE_URL=https://api.n4n.ai/v1
from dotenv import load_dotenv
load_dotenv()
Define the extraction schema
Function calling works by giving the model a JSON schema. LangChain converts a Pydantic model into that schema automatically. Start with a flat structure:
from pydantic import BaseModel, Field
from typing import List, Optional
class Person(BaseModel):
name: str = Field(description="Full name of the person")
age: Optional[int] = Field(default=None, description="Age in years if stated")
emails: List[str] = Field(
default_factory=list,
description="Associated email addresses"
)
class DocumentExtraction(BaseModel):
people: List[Person]
The description fields are not optional. The model uses them to decide which text spans map to which fields. Omit them and you’ll get weaker extraction.
Bind the model to the schema
ChatOpenAI exposes with_structured_output. This wraps the model in a Runnable that sends a function definition alongside the prompt and parses the returned arguments.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(DocumentExtraction)
If you are routing through a gateway, set base_url on the constructor. For example, n4n.ai provides one OpenAI-compatible endpoint covering 240+ models and will forward provider cache-control hints, so the same with_structured_output code works unchanged.
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0,
base_url="https://api.n4n.ai/v1", # optional gateway
)
Checkpoint: first extraction
Run a short block of text:
text = """
From: John Doe <john@example.com>
Jane Smith (age 34) reached out via jane@work.org. Also met Bob, no email.
"""
result = structured_llm.invoke(text)
print(result.model_dump())
Expected output (field order may vary):
{
"people": [
{"name": "John Doe", "age": null, "emails": ["john@example.com"]},
{"name": "Jane Smith", "age": 34, "emails": ["jane@work.org"]},
{"name": "Bob", "age": null, "emails": []}
]
}
The model inferred John Doe from the From: line and attached his email. Bob has no email, so the list is empty rather than null.
Extract from a realistic support ticket
Real documents are noisier. Extend the schema to capture the ticket metadata:
from enum import Enum
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class Ticket(BaseModel):
id: str = Field(description="Ticket identifier like TK-1234")
subject: str
priority: Priority
requester: Person
mentions: List[Person] = Field(
default_factory=list,
description="Other people named in the ticket"
)
class TicketExtraction(BaseModel):
ticket: Ticket
Bind and run:
ticket_llm = llm.with_structured_output(TicketExtraction)
ticket_text = """
TK-4821: VPN drops every 10 minutes
Priority: high
From: Alice Nguyen <alice@corp.com>
CC: Bob (bob@corp.com), Carol
The VPN tunnel collapses under load. Carol said she sees the same on WiFi.
"""
out = ticket_llm.invoke(ticket_text)
print(out.ticket.priority, out.ticket.requester.name)
Output:
Priority.HIGH Alice Nguyen
out.ticket.mentions will contain Bob with his email and Carol with none. The enum forces the model to pick one of the three strings; if it returns urgent the parse step raises before your code sees it.
Strict mode and validation
In this langchain function calling structured extraction tutorial we enable strict mode to lock down the schema. For models that support strict JSON schema (recent OpenAI and some open weights), pass strict=True:
strict_llm = llm.with_structured_output(TicketExtraction, strict=True)
Strict mode disables “additional properties” and requires all fields present, which reduces hallucinated keys. If the model still returns a missing required field, LangChain raises OutputParserException. Catch it and fall back to a cheaper model or a retry with a clearer prompt:
from langchain_core.exceptions import OutputParserException
try:
data = strict_llm.invoke(ticket_text)
except OutputParserException as e:
print("parse failed:", e)
data = ticket_llm.invoke(ticket_text) # non-strict fallback
Why function calling beats prompt-only JSON
Earlier LangChain versions relied on asking the model to “return JSON” and then parsing with json.loads. That breaks when the model wraps output in markdown fences or adds commentary. Function calling moves the contract into the API layer: the model returns arguments to a named function, and the provider guarantees well-formed JSON matching the schema. You lose the temptation to write response.text.strip().lstrip("```json") in production.
Also, function calling lets you register multiple schemas and let the model pick. For a router that decides between TicketExtraction and PersonExtraction, bind a union or use a two-step classify-then-extract pattern.
Debugging extraction failures
When strict=True raises, print the raw message:
raw = llm.invoke(prompt.format_messages(input=ticket_text))
print(raw.additional_kwargs.get("function_call"))
This shows what the model actually attempted. Common fixes: tighten field descriptions, add examples in the system prompt, or split the document. If you see the model returning emails: null instead of [], set default_factory=list and avoid Optional[List] unless you truly need tri-state.
Handling nested lists at scale
When you expect many entities, cap the work per call. Split a 50-page PDF into sections and extract per section, then merge:
def extract_sections(sections: List[str]) -> List[DocumentExtraction]:
results = []
for sec in sections:
r = structured_llm.invoke(sec)
results.append(r)
return results
If a section returns zero people, that’s a valid empty list, not an error. Log the section id and move on.
Prompt engineering still matters
Function calling is not a substitute for a clear instruction. Prepend a system message:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "Extract every person and their contact info. If age is not stated, omit it."),
("user", "{input}")
])
chain = prompt | structured_llm
chain.invoke({"input": text})
The schema tells the model how to format; the system message tells it what to look for.
Production considerations
- Set
temperature=0for deterministic extraction. - Cache schema definitions; rebuilding Pydantic models per request wastes CPU.
- Meter token usage. If you use a gateway with per-token usage metering, inspect
response.usageon the raw call to track cost per document type. - Validate downstream. Trust but verify: a
Personwithage: 999passed Pydantic but fails business logic.
Wrapping up
You now have a runnable langchain function calling structured extraction tutorial pattern: define Pydantic, bind with with_structured_output, and pipe text through a prompted chain. Swap the model name or base URL without touching the schema, and keep strict mode on for high-value pipelines.