The Advice That Aged Out
The classic industry wisdom says: never rewrite a working production system. Joel Spolsky called it the single worst strategic mistake; the Netscape rewrite is the textbook cautionary tale. The advice was correct because the economics were correct: a rewrite meant months of team time, running two systems in parallel, and the risk of losing implicit knowledge baked into the old code.
In August 2026, we rewrote a production backend from Python to Go in 7 days. One engineer. Zero downtime. In autumn we did it again on a second project — and realized it was no longer a stunt, it was a playbook. This post is that playbook, with the numbers, the architecture, and the CI setup that keeps the result alive.
Why Go, and Why Now
Our reasons: one static binary, types at every boundary, data races caught by the compiler
and by -race, and a deploy that is "a binary plus a 15-line Dockerfile". For a team
of one engineer and a swarm of AI agents, that meant fewer places to silently break. And the one
serious argument against a rewrite — the cost — is exactly the thing AI agents removed. The
mechanical transfer of contracts, handlers, and tests is agent work now. The human work is
architecture, priorities, and contract review. When the cost of a rewrite drops from "team
months" to "one engineer-week", the decision equation changes.
The Scope of the Work, in Numbers
| Before (Python) | After (Go) | |
|---|---|---|
| Lines of code | ~79,000 (two apps, 393 files) | ~13,000 on cutover day |
| Direct dependencies | 25+ (FastAPI, SQLAlchemy, Alembic, Pydantic, httpx, Playwright with headless Chromium, schedulers, PDF parsing…) | 1 meaningful: the PostgreSQL driver. Everything else is stdlib |
| Today | — | 60,000 lines of code + 19,000 lines of tests across a monolith, two standalone services, and a shared core |
79,000 lines of Python became 13,000 lines of Go — not because functionality was lost, but because three years of dead code, triple-duplicated schemas (Pydantic models mirroring SQLAlchemy models mirroring OpenAPI schemas), and whole layers replaceable by a 40-line generic helper simply left.
Project One: Four Phases in Seven Days
By August, the backend consisted of two Python applications — a FastAPI monolith and a Telegram-bot service — plus a separate MCP server exposing tools to AI agents. Containers, dependencies, and personalities of their own. Meanwhile we were developing alongside AI agents, and the codebase was growing faster than we could understand it. Python with proxy typing (mypy, Pydantic) worked fine, but every change required holding the whole context in your head: where runtime errors hide, where the implicit contracts are, where dependencies will rot next.
| Day | What happened |
|---|---|
| 1 | Skeleton of the new backend: 42 files, 2,500 lines — router, health, landing page, tests |
| 2–4 | Auth, admin, Telegram layer with multi-token config and bearer authentication. Each module migrated with contract tests — the old API was the living specification |
| 5 | Cutover by runbook: stop writes to the old backend, migrate data with a script, deploy the Go monolith, switch nginx, diff old-vs-new API responses with a comparison script, load test. Rollback was written down — never needed |
| 7 | Old Python code deleted from the repository. Later: shared core + standalone services extracted |
The key detail: phases ran in parallel with a living production. Python kept serving traffic the whole week — features shipped, incidents got handled. The new system grew on the side and replaced the old one in a single cutover day.
Project Two: Same Playbook, More Aggressive
In autumn we repeated the migration on a second project with messier legacy: a Node.js (NestJS) backend plus the remains of Python bots, half a dozen small services. Three accelerators came from lessons learned:
- A reference pattern. The first migrated service became the template; agents migrated the rest by literally copying patterns — auth middleware, media routes, admin APIs, test helpers.
- Parallelism. Services migrated simultaneously in separate git worktrees, each agent at its own pace, merging as ready.
- Simplification along the way. We also dropped the web framework (Fiber) for stdlib net/http: at our loads the framework added nothing, and the dependency tree shrank.
By October every service on the second project ran Go. Two migrations in a row on different stacks — the playbook stopped being an experiment.
Dropping the ORM: Deliberate Simplification
In the Python era, all data access went through SQLAlchemy plus Alembic migrations. Rewriting in
Go, we chose: no ORM — pgx and honest SQL only. At one point an agent even
suggested "adding GORM to protect against SQL injection". We audited every place with dynamic
SQL and declined: every query in the codebase is already parameterized ($1, $2, $3),
so injection physically cannot reach the database. An ORM would solve a problem we do not have
and create the ones we do.
-- a typical repository. all the magic is visible:
INSERT INTO landing_contact_forms (name, email, message)
VALUES ($1, $2, $3)
RETURNING id
- Queries are predictable. No N+1 you cannot see in the code; no "what did the ORM generate this time".
- Debugging is trivial. Copy the SQL from code into psql — it runs, because it is the same SQL.
- Fewer dependencies. One driver instead of an ORM stack with its migration machinery. Migrations are plain SQL files under golang-migrate.
- Enforced hygiene. Linters catch unclosed
rows.Close(), uncheckedrows.Err(), lostcontext— the things ORMs hide inside.
The price: slightly more boilerplate for simple CRUD. In practice, 150–200 lines of honest SQL per module, which agents generate in minutes.
How CI Works, and How We Catch Races
Linters are not ceremony. Of the 22 enabled, the most valuable for us:
| Linter | What it catches |
|---|---|
errcheck | Unhandled errors |
bodyclose, sqlclosecheck, rowserrcheck | Connection leaks and unclosed rows — the things ORMs usually hide |
contextcheck, noctx | Lost context.Context in call chains |
errorlint, wrapcheck | errors.Is/As instead of comparisons; error wrapping at package boundaries |
govet | Including part of the concurrency checks |
gosec | Security patterns (SQL, paths, crypto) |
modernize | Rewrites to modern Go: slices/maps, range-over-int |
Races. go test -race runs on every commit — the race detector is
built into the runtime and checks actual execution, not static guesses. On top of that we stress
suspicious tests with -count=100 -race: that catches both data races and logical
conditions that show up once in twenty runs. A live example: the chat service stored a pending
question as map[chatID]session, but several agent tokens legitimately share one
chat — a test hammering concurrent FIFO ordering caught the bug before production. The second
layer is in-memory repositories: every module ships a memory implementation of
its repository interface, so tests run without a database — deterministic, fast, and still
exercising real concurrent handler paths under the race detector.
Skill Files: Why the Go Code Comes Out Consistent
An AI agent writes the way it was taught. We maintain a set of 46 Go skill files
— instructions the agent loads before a task: concurrency (goroutine ownership, channels),
database (pgx, parameters, transactions), error handling (%w, sentinel errors),
context, observability (slog, metrics), testing (table-driven, testify). A router skill picks
the right ones per task. The database skill outright bans ORMs and requires parameterized
queries — which is why the codebase does not drift into a different style with every session.
A few dozen skill files replaced what a human team calls code review and onboarding.
Repository: github.com/samber/cc-skills-golang.
How It Is Set Up Now
Before: six containers — a FastAPI monolith (API, Telegram bots, MCP aggregation, a screenshot service on headless Chromium, schedulers), a separate bot service, a Python MCP server, a Valkey queue broker (Redis-compatible), an LLM proxy, and PostgreSQL.
After: five Go containers, no broker, no runtime.
The queues and schedulers left together with Python and never needed a replacement: background work is goroutines with tickers, state lives in PostgreSQL, and the inter-service messaging Valkey existed for vanished — because the components stopped being separate. What looked like "microservice coordination" became function calls inside one process.
Deploys are hands-off: a commit to main runs CI, then an Ansible playbook builds the Docker
image on the host and rolls the container. Each service is self-contained — its own
go.mod, its own database, its own container; the shared core (config, HTTP helpers,
the pgx wrapper) is one common module.
The Resource Bill
| Container | Python era | Go now |
|---|---|---|
| API monolith | 150–180 MB | 23 MB |
| Bots | ~100–150 MB | 24 MB |
| MCP server | ~80–120 MB | 13 MB |
| Queue broker (Valkey) | 30–50 MB | — not needed |
| LLM proxy | ~200–300 MB | — not needed |
| Utility worker | — | 17 MB |
| Backend total | ~600–800 MB, 6 containers | ~102 MB, 5 containers |
CPU sits at single-digit percentages even at peaks. The host that used to be "busy with the backend" now mostly idles — which is why it also runs CI runners and monitoring.
What Took Longer Than Expected
- Frontend contracts. The frontend expected fields the Go handler did not return — visually identical JSON broke the UI. Lesson: migrate from real response examples, not from types.
- Env variable names. The Python era hid config in
.envfiles; Go demanded one standard with prefixes and nesting. Every missed prefix was an hour of "why is this empty" debugging. - Database migration ordering. New services adopted databases where the migration tool already tracked versions. We had to write compatible rows into the versions table to avoid manual fixes.
Notice: all three are about seams, not about the language. The actual transfer of handlers and repositories was faster than we could review it.
What We Would Do Differently
- Contract fixtures before code. We would save 100% of real old-backend API responses as JSON and run the new backend against them from day one — the frontend-contract story would not have happened.
- Not migrate everything at once. Some modules could have stayed on Python longer. The monolith moved whole because it was small; at larger scale, strangler fig is safer.
- PGO from day one. Profile-guided optimization was added after the fact — it should have been in the first deploy.
Verdict
The economics of rewriting have changed, permanently. When an agent does the mechanical port and the human spends a week on architecture and contracts, the "too expensive to rewrite" argument leaves the equation. What remains are two honest questions: does the target stack give you real advantages, and are you willing to spend a week verifying contracts. If yes — switching now costs nothing.
Python stayed where it is good: data-migration scripts, analytics, CLI tools. The backend is Go only. And the next frontier of savings is not the language or the infrastructure — it is the resources of the agents themselves, which is the subject of part two.


