Compare

AI orchestration vs a durable task queue

Hatchet is a durable task queue on Postgres. ZenML is open-source AI orchestration for Python: dynamic pipelines, wait() approvals, caching, sandboxes, versioned artifacts, and one stack for your clouds.

Hatchet is a distributed task queue with durable execution on top, backed by Postgres. You get workers, queues, concurrency and rate-limit controls, cron, durable sleeps, and a dashboard for the whole fleet, with SDKs in Python, TypeScript, Go and Ruby. If your problem is pushing a lot of mixed background work through reliably, that’s what Hatchet is for, and it’s good at it.

ZenML is AI orchestration for Python. It runs pipelines and agents on your own infrastructure and remembers what they did. A dynamic pipeline builds its graph from plain Python, and wait() pauses a run until a human or another system answers. Failed steps retry with backoff, steps you didn’t touch come from cache, and agent tool loops run inside a sandbox.

So do you need Hatchet underneath ZenML to run an agent durably? For approvals, loops, retries and sandboxed tool use, no. For queues, concurrency keys and rate limits across a general task fleet, Hatchet is the better tool, and ZenML doesn’t try to be one.

ZenML

Use ZenML if you are

  • Orchestrating Python pipelines and agents (training, batch inference, evaluation pipelines, agent workflows) and want versioned artifacts and lineage by default
  • Pausing a run for human approval, looping or fanning out at run time, and retrying failed steps without bolting on a second engine
  • Running agent tool loops in an isolated sandbox and serving a pipeline as an HTTP endpoint with a tracked run per request
  • Targeting Kubernetes, Vertex AI, SageMaker, AzureML or Airflow and want one pipeline definition that runs on all of them

Hatchet

Use Hatchet if you are

  • Running a high-throughput task fleet where queueing, concurrency keys and rate limiting are the hard part
  • Coordinating general background work (webhooks, billing jobs, fan-out) that isn't an AI workload
  • Wanting event subscriptions, cron and durable sleeps that consume no worker resources while they wait, all as platform features
  • Operating across TypeScript, Go and Ruby as well as Python
Hatchet decides how work gets dispatched. ZenML runs the AI workload and can tell you afterwards exactly what it produced.

One orchestrator vs a task queue underneath

Hatchet’s model is tasks and workers: you register functions, workers pull from queues, and the engine handles retries, concurrency and scheduling. ZenML’s model is a pipeline of typed steps whose outputs get stored and wired together, run by whatever orchestrator is already in your stack.

Hatchet · platform across the stackEngine, workers, queues, and dashboard packaged together
Hatchet platform
events · cron · webhooksdurable engine · workers · queuesconcurrency · rate limits · prioritydashboard · OTEL · alerts
your app re-shapes around the engine
Adopt the platform, get the runtime as one part of it.
ZenML · one orchestrator, your infrastructureRuns pipelines and agents where you already have compute; no engine to operate
AgentPydantic AI, OpenAI Agents, LangGraph, raw Python
ZenML@pipeline(dynamic=True), @step, wait(), sandbox, deploy
StackKubernetes, Vertex AI, SageMaker, AzureML, Airflow, Databricks
Bucketversioned artifacts in your S3, GCS or Azure Blob
No second engine underneath. Your clouds run it; ZenML records it.
  • Unit of work: A Hatchet task is a unit of scheduling. A ZenML @step is a unit of record: its return value gets stored and versioned.
  • Who runs it: Hatchet runs tasks on workers you deploy and look after. ZenML hands the pipeline to the stack orchestrator (Kubernetes, Vertex AI, SageMaker, AzureML, Airflow or Databricks) and records the run.
  • What you adopt: Hatchet is a platform you route work through. ZenML is a library you write pipelines in, plus a server that records them. There’s no engine of its own to operate.

Durable waits and dynamic graphs, without a second engine

Agents loop, branch, fan out and stop to ask a human. Hatchet covers that with durable tasks and child workflows. ZenML covers it inside the pipeline itself, and every branch taken leaves an artifact behind.

A human approval gate in Hatchet and in ZenML.Hatchet pauses a durable task on a user event and can evict it to free the worker slot. ZenML pauses a dynamic pipeline at wait(), marks the run PAUSED after the timeout, and resumes it with a CLI command.
Hatchet · durable task on a user eventThe worker slot is released the moment the wait starts
researchtask
approvedurable_task
publishtask
await ctx.aio_wait_for("approval", UserEventCondition(...))
Resumes from the journal when the event arrives. Outputs stay in run history; artifacts are your call.
Durable sleeps and event waits are platform features.
ZenML · wait() in a dynamic pipelineRun paused, artifacts kept, resumed on your command
researchartifact
wait()PAUSED
publishif approved
zenml pipeline runs resume <run>
Polls until the timeout, then the run is marked PAUSED and the process can go. Resolve from the dashboard, CLI or API.
Approval gate, loops and fan-out inside the pipeline. No second engine.
  • Dynamic pipelines: @pipeline(dynamic=True) builds the graph at run time from plain Python: for loops, if branches, and step.map() fan-out over a list. Hatchet gets the same shape by spawning child workflows from a task.
  • wait(): Inside a dynamic pipeline, wait(schema=bool, question=...) pauses the run until someone answers from the dashboard, the CLI or the API. Until the timeout the orchestration process keeps polling. After that the run is marked PAUSED, the process can go away, and zenml pipeline runs resume picks it back up. Hatchet’s durable aio_wait_for consumes nothing while it waits, can be evicted to free the worker slot, and resumes on the event.
  • Failure handling: StepRetryConfig retries a step with delay and backoff. zenml pipeline runs retry retries a failed dynamic run, and completed steps are reused rather than re-executed. ZenML retries the step; Hatchet resumes a durable task mid-body from its journal.
  • Sandboxes: A sandbox stack component gives an agent step an isolated session to run generated code in. It comes in local, Docker, Kubernetes and Modal flavors; Docker and Modal add snapshot and restore. Hatchet has nothing like it; tools run on the worker.

Where the cache boundary falls

Edit one step in the middle of a pipeline and re-run. What happens to the steps above it decides how fast you iterate and what it costs you.

Hatchet · replay from the event logRe-running a workflow starts it from the top
researchre-run
draftre-run
publishre-run
Restart from the top of the run. Earlier steps execute again unless you've hand-rolled an override layer inside step bodies.
Operational replay. Not parameterised over a specific step's output.
ZenML · the cache boundaryChange one step; upstream stays cached, downstream re-executes
researchcached
draftchanged
publishre-execute
python pipeline.py # draft edited
Edit one step. Downstream re-executes against the new value; upstream is served from cache.
Built-in. No event-log surgery, no per-step override scaffolding.
  • ZenML: The cache key is the step, its parameters and its inputs. Edit draft and re-run: research above it comes from cache and never runs, draft and everything below it re-runs against the new value.
  • Hatchet: Re-running a workflow starts it from the top. Skipping finished work is yours to build inside task bodies, against your own store.
  • When it applies: ZenML’s caching isn’t just for recovery. It kicks in on every run, including the boring case where you edited one step and re-ran the file. When the agent itself changes, Kitaru replays recorded production runs against the new version, which is a different job from orchestrating it.

What makes ZenML different

FeatureZenMLHatchetWhat that means
Caching on ordinary re-runs, not only on recoveryYesNot supportedZenML skips steps whose parameters and inputs are unchanged, on every run.
Versioned artifacts and lineage across runsYesNot supportedStep outputs are stored and loadable by name; Hatchet leaves artifact handling to you.
Graph built at run time from Python loops, branches and fan-outYesYesZenML: @pipeline(dynamic=True) with step.map(). Hatchet: child workflows spawned from a task.
Pause a run for human approval and resume it laterYesYesZenML: wait() in a dynamic pipeline, resolved from dashboard, CLI or API. Hatchet: durable tasks waiting on a user event.
Durable sleeps that release compute immediatelyPartial supportYesZenML's wait() polls until its timeout, then marks the run PAUSED so the process can go away. Resume is a CLI command, or automatic in ZenML Pro.
Sandboxed code execution for agent tool loopsYesNot supportedStack component with local, Docker, Kubernetes and Modal flavors; Docker and Modal sessions can be snapshotted and restored.
Serve a pipeline as an HTTP endpoint with a tracked run per requestYesPartial supportzenml pipeline deploy on local, Docker, Kubernetes, Cloud Run or App Runner. Hatchet exposes a trigger API; the request/response service is yours to write.
One stack for Kubernetes, Vertex AI, SageMaker, AzureML, Airflow, DatabricksYesNot supportedConfigure once, every pipeline uses it. Hatchet runs on workers you operate.
Resume a failed task mid-body from a journalNot supportedYesZenML retries at step granularity; on a retried dynamic run, completed steps are reused rather than re-executed.
Queueing, concurrency keys and rate limitingNot supportedYesHatchet's home turf. ZenML hands execution to the stack orchestrator.
Native cron and event subscriptionsPartial supportYesZenML hands scheduling to the orchestrator (Kubernetes, Vertex AI, SageMaker, Airflow and others); ZenML Pro adds server-side schedules. Pipelines are invoked, scheduled or deployed; they don't subscribe to external events, though ZenML Pro can trigger a pipeline when another run finishes.
TypeScript, Go and Ruby as well as PythonNot supportedYesZenML is Python only.
Open source, self-hostableYesYesZenML: Apache 2.0, Helm chart for the server, artifacts stay in your own bucket.

How the two surfaces map

ConceptHatchetZenML
Workflow boundaryhatchet.workflow(name=...)@pipeline or @pipeline(dynamic=True)
Unit of work@wf.task() (queued, retried)@step (ordinary Python, versioned output)
Run-time branching and fan-outChild workflows spawned from a taskPython control flow and step.map() in a dynamic pipeline
Human approval@wf.durable_task() waiting on a UserEventConditionwait(schema=..., question=...), resolved via dashboard, CLI or API
Skipping completed workDurable resume after failureenable_cache on every run, plus zenml pipeline runs retry for a failed dynamic run
Isolated tool executionOn the workerClient().active_stack.sandbox.create_session()
ServingTrigger API, service is yourszenml pipeline deploy, a tracked run per request
SchedulingCron, events, queuesDelegated to the stack orchestrator; server-side schedules in ZenML Pro
Where it runsHatchet workers you deployStack orchestrator (zenml stack set)
Cross-run reuseBring your own storeClient().get_artifact_version(...).load()

Code comparison

ZenML
from typing import Annotated

from zenml import pipeline, step, wait
from zenml.client import Client
from zenml.config.retry_config import StepRetryConfig

@step(retry=StepRetryConfig(max_retries=3, delay=10, backoff=2))
def research(topic: str) -> Annotated[str, "brief"]:
  return call_llm(f"Research: {topic}")

@step
def draft(brief: str) -> Annotated[str, "draft"]:
  return call_llm(f"Write a draft from:\n{brief}")

@step
def publish(text: str) -> None:
  send_to_cms(text)

@pipeline(dynamic=True)
def review_pipeline(topic: str):
  text = draft(research(topic))
  approved = wait(
      schema=bool,
      question="Publish this draft?",
      name="human_approval",
  )
  if approved:
      publish(text)

review_pipeline("AI orchestration")

# The run pauses at wait(). Resolve it in the dashboard, or:
#   zenml pipeline runs wait-conditions resolve --run <id> --interactive
#   zenml pipeline runs resume <id>
# Edit draft() and re-run: research comes from cache.
Client().get_artifact_version("draft").load()
Hatchet (Python SDK v1)
from hatchet_sdk import Context, DurableContext, Hatchet, UserEventCondition
from pydantic import BaseModel

hatchet = Hatchet()

class ReviewInput(BaseModel):
  topic: str

review = hatchet.workflow(
  name="review", input_validator=ReviewInput, on_events=["review:create"]
)

@review.task(retries=3, backoff_factor=2)
def research(input: ReviewInput, ctx: Context) -> dict:
  return {"brief": call_llm(f"Research: {input.topic}")}

@review.task(parents=[research])
def draft(input: ReviewInput, ctx: Context) -> dict:
  brief = ctx.task_output(research)["brief"]
  return {"draft": call_llm(f"Write a draft from:\n{brief}")}

@review.durable_task(parents=[draft])
async def approve(input: ReviewInput, ctx: DurableContext) -> dict:
  # Consumes nothing while it waits; the worker slot can be freed.
  await ctx.aio_wait_for(
      "approval", UserEventCondition(event_key="review:approved")
  )
  return {"approved": True}

@review.task(parents=[approve])
def publish(input: ReviewInput, ctx: Context) -> dict:
  send_to_cms(ctx.task_output(draft)["draft"])
  return {}

# Workers pull from the queue; the engine owns retries, concurrency
# and rate limits. Task outputs live in the run's history, so
# persisting artifacts is your call.
worker = hatchet.worker("review-worker", workflows=[review])
worker.start()

One orchestrator for
pipelines and agents

If the hard part is throughput, queueing, and keeping a large fleet of background tasks reliable across four languages, Hatchet is the platform to adopt. If the hard part is running pipelines and agents on your own infrastructure, pausing for approval, retrying what failed, sandboxing tool use, and knowing afterwards exactly what each run produced, ZenML does that on its own. You don’t need Hatchet underneath it.