Why I write agents in Go, not Python
Lessons from shipping a production agentic runtime in Go after years of Python stacks. Concurrency, deployment, observability, and why the type system pays for itself.
The first production agent I ever shipped was a Python service. It was a single
asyncio event loop, a handful of tools, and a prompt template. It worked. It
also could not handle a hundred concurrent conversations without falling over,
took fifteen seconds to cold-start on a tiny container, and made every error
visible only as a string in a log file. The team I built it with spent more
time debugging the runtime than tuning the agent.
Two years later I rewrote the same shape of service in Go for a different
client. The runtime was a go func per agent request, the tools were plain
functions with typed signatures, and the whole binary shipped as a static
binary that cold-started in forty milliseconds. The team spent its time tuning
the agent. The runtime stopped being the bottleneck.
This post is the short version of what I learned in between.
Concurrency that does not surprise you
Python’s asyncio is fine for I/O-bound work, but an agent runtime is not
I/O-bound in the way the documentation suggests. Every tool call is a mix of
I/O (waiting on an LLM response, a database query, an external API) and CPU
work (parsing the response, building the next prompt, validating the output).
You can do CPU work in asyncio with run_in_executor, but now you have two
concurrency models in the same service and every team member has to remember
which one they are in.
Go gives you one concurrency model: goroutines. They are cheap, they are preemptive, and they communicate through channels. Every tool call in the runtime is a goroutine. Every LLM call is a goroutine. The agent director is a goroutine. When the conversation ends, the goroutines are garbage-collected. There is no async/await syntax, no event loop, no thread pool to size. The mental model is “a goroutine per unit of work” and that is the entire model.
Deployment that is just a binary
Python services need a base image, a virtual environment, a requirements file, and a startup script. The image is hundreds of megabytes. The startup script is where the bugs live. The first time you ship a Python service to production, you will spend at least one afternoon debugging an import error that only happens in the container.
Go services need a binary. CGO_ENABLED=0 go build produces a static binary
that runs on any Linux with the same kernel the build target specified. The
container is a FROM scratch with the binary copied in. The image is
fifteen megabytes. Cold start is the time it takes the kernel to load the
binary. There is no virtual environment, no requirements file, no startup
script. The first time you ship a Go service to production, you will spend
the afternoon reading logs of the service running correctly.
Observability that survives a refactor
Python is dynamically typed. When you refactor an agent runtime, the type checker does not catch the changes. You find out at runtime, in production, when an attribute that used to be a string is now a dict. The traceback is ten frames deep and the root cause is the refactor you did three weeks ago.
Go is statically typed. When you refactor an agent runtime, the compiler catches the changes. The errors are at the line where you made them. The refactor is safe to merge because the build is green. When something does go wrong at runtime, the structured logs (slog is in the standard library) tell you which goroutine failed and what the typed inputs and outputs were.
The one thing Python is better at
Python has the larger ecosystem for ML work. The LLM client libraries, the vector databases, the prompt templating engines — they all have Python as their first-class language. If your team is doing research, if you are prototyping a new agent shape, if the production runtime is a week away, use Python. The iteration speed matters more than the runtime cost.
If your team is shipping to production, if the agent is a service that has to handle real load, if the runtime cost is part of the bill — use Go. The iteration speed cost is real but it is paid once, in the design phase. The runtime cost is paid every day the service is live.
The short version: write the prototype in Python, rewrite the production runtime in Go. Do not try to make the Python prototype production-ready. Do not try to write the prototype in Go. Use the right tool for each phase.