CrewAICrewAI 1.15 · Python 3.10 to 3.13
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
35 small wins to finish your pathNext lesson →

Structured output with output_pydantic

output_pydantic makes a task return a Pydantic object instead of free text, so the next piece of code reads fields rather than parsing a sentence.

Last updated: 28 Sep, 2026 · CrewAI 1.15

The writer from the last lesson returns an email as text. The shop's ticket system needs the order id and status as separate fields, checked and typed.

The output_pydantic option

Give a task a Pydantic model as output_pydantic. CrewAI appends the schema to the prompt, then parses and validates the answer into that model.

python
class Reply(BaseModel):
    order_id: str
    status: str

reply.output_pydantic = Reply    # ask for this shape and validate against it
result.pydantic     # a Reply instance
result["status"]    # index the result by field name

The model

A Pydantic model lists the fields and their types. It is plain Pydantic, not a CrewAI class.

python
from pydantic import BaseModel


class Reply(BaseModel):
    order_id: str
    status: str
    email: str

Scripting the writer's JSON

Writing valid JSON for any schema is beyond a few lines of Python, so the stand-in writer is scripted to answer in JSON for this run. A hosted model given the schema writes it itself.

python
writer.llm.script = ['{"order_id": "A17", "status": "shipped",'
                     ' "email": "Dear customer, A17 shipped on 3 March."}']
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
tools.py
from crewai.tools import tool

ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}


@tool
def lookup_order(order_id: str) -> str:
    """Look up an order's shipping status by its id, such as A17."""
    status = ORDERS.get(order_id)
    return f"{order_id} {status}." if status else f"{order_id} is not an order we have."
shop_llm.py
import json
import os
import re

from crewai import BaseLLM

os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
os.environ["CREWAI_TRACING_ENABLED"] = "false"
os.environ["CREWAI_DISABLE_VERSION_CHECK"] = "true"


class ShopLLM(BaseLLM):
    script: list = []

    def supports_function_calling(self):
        return True

    def call(self, messages, tools=None, **kwargs):
        if isinstance(messages, str):
            messages = [{"role": "user", "content": messages}]
        if self.script:
            return self.script.pop(0)
        names = [t["function"]["name"] for t in tools or []]
        return self.decide(messages, names)

    def decide(self, messages, tools):
        last = messages[-1]
        if last["role"] == "tool":
            return last["content"]
        text = last["content"]
        orders = re.findall(r"\b[A-Z]\d+\b", text)
        wanted = "refund_order" if "refund" in text.lower() else "lookup_order"
        matches = [name for name in tools if name.endswith(wanted)]
        if orders and matches:
            args = json.dumps({"order_id": orders[0]})
            return [{"id": f"call_{orders[0]}", "type": "function",
                     "function": {"name": matches[0], "arguments": args}}]
        if "working with:" in text:
            context = text.split("working with:")[1].strip().split("\n\n")[0]
            return f"Dear customer, {context}"
        if orders:
            return f"I have no way to look up {orders[0]} yet."
        return "Hello. Which order is this about?"

A typed reply end to end

The crew from the last lesson, with the reply task now typed.

Example
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"

from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order

clerk = Agent(role="Order clerk", goal="Find the status of customers' orders",
              backstory="You can look up any order in the shop's system.",
              llm=ShopLLM(model="shop"), tools=[lookup_order])
writer = Agent(role="Reply writer", goal="Write replies to customers",
               backstory="You write short, friendly emails.",
               llm=ShopLLM(model="shop"))

look = Task(description="Find the order in this message: {question}",
            expected_output="The order's status.", agent=clerk)
reply = Task(description="Write the customer a reply.",
             expected_output="A short, friendly email.", agent=writer)
crew = Crew(agents=[clerk, writer], tasks=[look, reply], verbose=False)

from pydantic import BaseModel


class Reply(BaseModel):
    order_id: str
    status: str
    email: str

reply.output_pydantic = Reply
writer.llm.script = ['{"order_id": "A17", "status": "shipped", "email": "Dear customer, A17 shipped on 3 March."}']

result = crew.kickoff(inputs={"question": "Where is my order A17?"})
print(repr(result.pydantic))
print(result["status"])

What the typed result holds

  • result.pydantic is a real Reply object, its three fields filled from the JSON.
  • Indexing the result with a field name reads from that object, so result["status"] is the status string.
  • CrewAI validated the answer against the schema before handing it back, so the fields have the declared types.

A reply that does not fit

When the model answers with a plain sentence, it cannot become a Reply, and the run stops.

Example
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"

from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order

clerk = Agent(role="Order clerk", goal="Find the status of customers' orders",
              backstory="You can look up any order in the shop's system.",
              llm=ShopLLM(model="shop"), tools=[lookup_order])
writer = Agent(role="Reply writer", goal="Write replies to customers",
               backstory="You write short, friendly emails.",
               llm=ShopLLM(model="shop"))

look = Task(description="Find the order in this message: {question}",
            expected_output="The order's status.", agent=clerk)
reply = Task(description="Write the customer a reply.",
             expected_output="A short, friendly email.", agent=writer)
crew = Crew(agents=[clerk, writer], tasks=[look, reply], verbose=False)

from pydantic import BaseModel


class Reply(BaseModel):
    order_id: str
    status: str
    email: str

reply.output_pydantic = Reply
writer.llm.script = ["A17 shipped on 3 March."]

try:
    crew.kickoff(inputs={"question": "Where is my order A17?"})
except Exception as error:
    print(type(error).__name__)
    print(error)

The message in 1.15.22 mentions a missing agent although the task has one; the real cause is the text, which does not fit the schema. The next lesson sends a wrong answer back for another try.

output_pydantic vs raw text

Plain text (raw)output_pydantic
What you get backA string to parse yourselfA typed object with named fields
A wrong shapePasses through unnoticedRaises ConverterError
Reading a fieldString slicing or regexresult.pydantic.status

When to type a task's output

  • The answer feeds another program: a ticket system, a database row, an API call.
  • You need one field, not a paragraph, and want a hard error when it is missing.
Watch out. output_pydantic makes a mismatch stop the whole run, so pair it with the guardrail retry from the next lesson when a model may answer in prose. Use output_json=Reply instead when you want a dict in result.json_dict rather than an object.
Try it yourself
  • Add a field refund: bool = False to Reply and run the first example.
  • Script a reply whose status is a number and read the error.
  • Use output_json=Reply instead and print result.json_dict.

Every expert started right here.