Pydantic AI brings the Pydantic team’s approach to agents: typed dependencies, validated structured outputs, and a model-agnostic API that still reads like Python. If you want an agent whose output you can trust to match a schema, it’s a very good choice.
ZenML isn’t a competing harness. It’s the orchestration layer around the agent: your Agent runs unchanged inside a @step, and ZenML supplies the pipeline, the versioned artifacts, the cache, a wait() that pauses the run for a human, a sandbox for the code the agent’s tools execute, and the stack that puts it on Kubernetes, Vertex AI, SageMaker, AzureML and more.
Both are ordinary Python, so they compose cleanly. Pydantic AI validates what comes out of the model. ZenML stores what comes out of the step, remembers which run produced it, and decides what happens next.
ZenML
Use ZenML if you are
- Running agent work on a schedule or behind an endpoint, not from a terminal by hand
- Asked, weeks later, which output came from which run and which inputs
- Pausing for a human to approve what the agent produced before the next step acts on it
- Re-running often enough that skipping unchanged steps saves real money
- Deploying into your own cloud for security or compliance, with the outputs in your own bucket
Pydantic AI
Use Pydantic AI if you are
- Building the agent itself, and want typed dependencies and validated structured outputs
- Switching between model providers behind one API
- Wanting a framework that reads like ordinary, type-checked Python
- Using Logfire for tracing individual agent runs
Pydantic AI guarantees the shape of what the model returned. ZenML guarantees you can find it again, tells you which run it came from, and holds the run until someone approves it.
Different questions
“Agent framework” and “orchestrator” get used loosely enough that they sound like alternatives. They aren’t. Here’s the split:
- Pydantic AI asks: How do I define this agent, give it typed dependencies, call a model, and validate what comes back against a schema?
- ZenML asks: This ran. What did it produce, where is that stored, which inputs made it, does a human need to approve it, and do I have to compute it again?
- Little overlap: ZenML doesn’t define agents, it runs them. The agent loop, the tools and the output schema are Pydantic AI’s. ZenML owns the run around them, the approval gate, the sandbox the tools execute in, and the deployment.
What a pipeline adds on top
The agent code doesn’t change. What changes is what exists around it once it’s run.
@pipeline: Wraps the agent in a run with a status, a history, and steps you can inspect in the dashboard. Withdynamic=True, loops and branches in the pipeline body decide at run time which steps exist, and.map()fans an agent step out over a list another step returned.- Versioned artifacts: Return
Annotated[Brief, "brief"]and the validated Pydantic model is stored, versioned, and loadable by name across runs. The type survives the round trip. enable_cache: The cache is keyed on the step, its parameters and its inputs. Unchanged steps are served from cache on every run, so tuning the step after the agent doesn’t re-invoke the agent, andzenml pipeline runs retryafter a failed dynamic pipeline run re-runs only what didn’t complete.wait(): In a dynamic pipeline, pauses the run until a human resolves it from the dashboard, the API, or the CLI. The orchestration process can be torn down while it waits, andzenml pipeline runs resumepicks the run up again, or ZenML Pro resumes it automatically.- Sandboxes: A stack component (local, Docker, Kubernetes, Modal) whose sessions the agent’s tools call
exec()on. Thesandbox_pydantic_aiexample in the ZenML repo does exactly this: a Pydantic AI agent withrun_pythonandrun_shelltools backed by one session per subagent. zenml stack set: The same pipeline runs on Kubernetes, Vertex AI, SageMaker, or AzureML with no code change, and artifacts land in your own bucket. Self-host the server with Helm, or use ZenML Pro for the managed control plane.
Where they overlap, and where they don’t
Both projects touch observability, and it’s the one place you might expect a fight. There isn’t one.
- Logfire traces what happened inside an agent run: the model calls, the tool calls, the timings. That’s a level of detail ZenML doesn’t capture and doesn’t try to.
- ZenML records what happened around the step: which run, which inputs, which artifact version, which approval, on which stack. That’s a level Logfire isn’t aiming at.
- Teams run both, and during an incident they answer different questions.
Neither one lets you take a recorded run and re-run it against a prompt or model change. That’s Kitaru, which has a first-class Pydantic AI adapter: see Kitaru vs Pydantic AI.
What makes ZenML different
| Feature | ZenML | Pydantic AI | What that means |
|---|---|---|---|
| Versioned artifacts and lineage across runs | Yes | Not supported | Step outputs are stored and loadable by name, including validated Pydantic models. |
| Caching that skips unchanged steps on a re-run | Yes | Not supported | Keyed on the step, its parameters and its inputs; an agent re-run starts fresh. |
| Pause the run for human approval, resume later | Yes | Partial support | `wait()` in a dynamic pipeline pauses the whole run. Pydantic AI can defer a tool call for approval, but you own the storage and the process between turns. |
| Isolated code execution for tool loops | Yes | Partial support | ZenML's sandbox is a stack component with local, Docker, Kubernetes and Modal flavors. Pydantic's Code Mode (in pydantic-ai-harness) runs the model's tool-calling code inside the Monty sandbox, a Python subset interpreter. |
| One stack abstraction for Kubernetes, Vertex AI, SageMaker, AzureML and more | Yes | Not supported | Configure once, every pipeline uses it. |
| Serve it and get lineage per request | Yes | Not supported | `zenml pipeline deploy` makes every request a tracked run. |
| Works with any agent framework, not just one | Yes | Not supported | A step is ordinary Python. |
| Typed dependencies and validated structured outputs | Not supported | Yes | Pydantic AI's whole point. Use it inside the step. |
| Model-provider abstraction | Not supported | Yes | ZenML has no LLM primitive; call whichever SDK you like from a step. |
| Tracing inside an agent run (model and tool calls) | Not supported | Yes | Logfire's level of detail. ZenML records the step, not the calls within it. |
| Open source, self-hostable | Yes | Yes |
How the two surfaces map
| Concept | Pydantic AI | ZenML |
|---|---|---|
| Unit of work | Agent.run() | @step (the agent runs inside it) |
| Boundary | The agent and its dependencies | @pipeline |
| Control flow | The agent loop | The pipeline graph, or Python control flow with dynamic=True |
| Typing | Validated structured output | Annotated[T, "name"] on the artifact |
| Human approval | Deferred tool calls, your own persistence | wait() in a dynamic pipeline, resolved from CLI, dashboard or API |
| Code execution | Code Mode in the Monty sandbox | Sandbox stack component (local, Docker, Kubernetes, Modal) |
| Outputs | AgentRunResult in memory | Versioned artifacts in your bucket |
| Avoiding repeat work | Not applicable | enable_cache across runs, zenml pipeline runs retry after a failed dynamic run |
| Observability | Logfire traces inside the run | Runs, steps, and artifacts around it |
| Where it runs | Your process | Stack orchestrator (zenml stack set) |
| Serving | Your own wrapper | zenml pipeline deploy --name ... |
Code comparison
from typing import Annotated
from pydantic import BaseModel
from pydantic_ai import Agent
from zenml import pipeline, step, wait
from zenml.client import Client
class Brief(BaseModel):
summary: str
sources: list[str]
agent = Agent("anthropic:claude-sonnet-4-6", output_type=Brief)
@step
def gather_context(topic: str) -> Annotated[str, "context"]:
return retrieve_documents(topic)
@step(enable_cache=False)
def run_agent(context: str, topic: str) -> Annotated[Brief, "brief"]:
# The validated Pydantic model is stored as the artifact,
# so the type survives the round trip.
return agent.run_sync(f"{context}\n\nBrief on: {topic}").output
@step
def publish(brief: Brief) -> None:
post_to_cms(brief.summary)
@pipeline(dynamic=True)
def brief_pipeline(topic: str):
brief = run_agent(gather_context(topic), topic)
# Pauses the run until someone answers from the
# dashboard, the API, or the CLI.
approved = wait(schema=bool, question="Publish this brief?")
if approved:
publish(brief)
brief_pipeline("AI orchestration")
# Comes back typed, from any past run:
brief = Client().get_artifact_version("brief").load()from pydantic import BaseModel
from pydantic_ai import Agent
class Brief(BaseModel):
summary: str
sources: list[str]
agent = Agent("anthropic:claude-sonnet-4-6", output_type=Brief)
topic = "AI orchestration"
context = retrieve_documents(topic)
result = agent.run_sync(f"{context}\n\nBrief on: {topic}")
brief = result.output # validated Brief
if input("Publish this brief? ") == "y":
post_to_cms(brief.summary)
# The output is type-safe and Logfire can trace the model and
# tool calls inside this run. What there isn't: a run record
# across runs, a versioned artifact you can load back by name
# next month, a cache that skips retrieval on the next run,
# an approval that survives the process being torn down, or
# a way to put this on Vertex AI without writing it.Put a pipeline around
your Pydantic AI agents
These aren’t alternatives, and picking one doesn’t rule out the other. Build the agent with Pydantic AI, keep its typing and its tracing, and add ZenML when the agent becomes a workload: the outputs need versioning, a human has to sign off before the next step, re-running everything gets expensive, and it has to run on your own infrastructure.









