Build in Public #2: The Go Rewrite Hits Its First Real Bugs
Since the last post I've finished four phases of the Atlas Docs Go rewrite. The skeleton is up, models and validation are in place, SQLite is talking through sqlc, and auth works end-to-end. More usefully: two bugs showed up that only real interoperability testing could have caught. This one is about those.
Phase 1: Skeleton that actually shuts down
I started with chi for routing, log/slog for structured JSON logging (method, path, status, bytes, duration, request-id on every request), and a GET /api/health that matches the Python version exactly.
Graceful shutdown was the first reminder that Go makes you mean it. Under uvicorn it mostly feels automatic. In Go it's four explicit lines: a signal channel for SIGINT/SIGTERM, a select, a context with timeout, and srv.Shutdown(ctx) so in-flight requests drain before the process exits. I built the binary, curled the endpoint, sent SIGTERM, and watched it exit cleanly. Real verification, not vibes.
golangci-lint's errcheck also paid for itself on the first pass — an unchecked error return from a JSON encode call. Python's exceptions would have screamed about that at runtime. Go's compiler will happily let you ignore it unless a linter is actually wired in.
Phase 2: Structs, tags, and the silent footgun
I ported 7 of the 8 SQLAlchemy models and all the Pydantic request/response schemas to Go structs with json tags plus go-playground/validator tags.
The interesting difference: Pydantic's BaseModel does shape, validation, and serialization in one runtime-introspection package. In Go those are three separate, fully explicit things — a struct, a validate:"required,min=8,max=200"-style tag, and a Validate(v) call you have to remember to make yourself. Forget the call and nothing fails loudly. You just silently accept bad data. That's arguably the single easiest Pydantic-to-Go footgun, and it's still sitting in the back of my head every time I write a handler.
Phase 3: sqlc and the truncated query
This is where things got interesting.
I went with modernc.org/sqlite (pure-Go, no cgo — keeps cross-compiling and a tiny scratch Docker image trivial) and sqlc to generate typed Go query code from hand-written .sql files, instead of an ORM. Goose migrations mirror the Python schema. Query files per table. sqlc generate. So far, so clean.
Then I needed to port the "keep at most the newest 50 document version snapshots, delete anything older" logic — throttled autosave history. The clever one-statement version looked fine:
DELETE ... WHERE id NOT IN (SELECT ... ORDER BY ... LIMIT ...)
sqlc (v1.31.1) generated Go that looked completely normal: valid syntax, a plausible embedded SQL string. At runtime it failed with SQL logic error: incomplete input.
I opened the generated file. The embedded SQL string was missing its closing parenthesis. sqlc had silently truncated the query text.
I tried a different formulation — SQLite's LIMIT -1 OFFSET ? idiom, which is what SQLAlchemy's bare .offset(n) actually compiles to on SQLite (a fun fact most people never learn). Same class of bug, different truncation: this time cutting OFFSET ?; down to OFFS.
Then, trying to document the fix with an explanatory comment block between the query name and its SQL body, that triggered a different manifestation of the same underlying bug and corrupted a totally unrelated neighboring query in the same file.
The actual fix: I stopped fighting the tool. Three lines of plain Go — fetch all versions ordered newest-first, slice off everything past index 50, delete each by id. Simpler, sidesteps the bug entirely, and arguably more readable than the clever SQL would have been.
None of this would have been caught by reading the generated code and nodding. Only a test that executed the query against real SQLite caught it. Lesson: generated code needs the same "does it actually run" discipline as hand-written code — maybe more, because the DO NOT EDIT header at the top invites trusting it unread.
Phase 4: Auth, and the self-consistent lie
Phase 4 was scrypt password hashing, JWT (HS256) issuing/verification, and a chi middleware standing in for FastAPI's Depends(get_current_user). Shape difference worth noting: Python's dependency injection is declared per-route-function-parameter — each handler opts in explicitly. Go middleware wraps a whole subtree of routes at once, with no compiler-checked guarantee you mounted it on the right subtree.
This phase had its own, arguably better, war story.
I wrote HashPassword using a 32-byte derived key — the "normal" choice, the one muscle memory reaches for, like AES-256's key size. Every test passed: hash a password, verify it against itself, confirm two hashes of the same password differ (random salt), confirm malformed hashes fail closed instead of panicking. All green.
Then, out of paranoia, I generated one real password hash using the actual Python implementation in its real virtualenv and asserted Go's verify function would accept it.
It didn't.
Root cause: Python's hashlib.scrypt never states a key length parameter at all — and its default is 64 bytes, not 32. Every Go-only test passed regardless, because none of them ever checked against an independent reference value, only against themselves. Same lesson as the sqlc bug from a different angle: a test suite that only checks a system against itself proves internal consistency, not correctness.
Fixed by changing one constant from 32 to 64.
I cross-checked JWT the same way — generated a token with real PyJWT using a throwaway test secret (deliberately never printing or using the actual production secret key; that's live credential material) — and that one round-tripped cleanly on the first try, which was reassuring after the scrypt surprise.
The shared lesson
Both bugs share a lesson worth stating plainly: whenever this migration produces something that has to interoperate with an already-shipped Python artifact — a password hash, a JWT, eventually a Tiptap JSON blob or a sync timestamp comparison — the test needs a real reference value pulled from the actual Python implementation, not just self-consistency against its own Go output.
Self-consistency is easy and comforting. Interop is the only thing that matters for a rewrite.
What's next
Phase 5: port the actual HTTP endpoints — auth routes, documents CRUD, the sync endpoint with its last-write-wins logic, comments, share links, publish-to-vault — each with table-driven tests. Then config/logging polish, a multi-stage Docker build, and a side-by-side deploy on its own subdomain. Still not replacing the live Python app; that's a deliberate later decision. Head-to-head benchmarks once there's enough surface area for the comparison to mean something.
Four phases in, the rewrite is less "ports go cleanly" and more "trust nothing that hasn't been forced to talk to the original." That's fine. That's the point.