Backends — Go vs Rust
Sister project shop-two-backends ships the same shop endpoints twice — once in Go (chi + sqlc) and once in Rust (axum + sqlx). Each comparison below shows the actual source files side-by-side, with notes on what's worth taking away.
Goal: make a Go developer who reads both columns say "okay, I see why I'd switch for this kind of code" — without pretending Go does nothing well. The Where Go shines page is the honest counter-pitch: five places where Rust is the wrong call.
Heads up: the sibling shop-two-backends
checkout was not found at build time, so the comparison code
panels will be empty. Clone it as a sibling directory and rebuild.
-
Typed SQL: sqlc vs sqlx::query!
baselineBoth backends serve the same /products endpoints. The contrast is in how the SQL is written and checked — sqlc generates Go from a .sql file offline; sqlx checks inline SQL against a live database at compile time.
-
Concurrent fan-out: errgroup vs JoinSet
concurrencyEach warehouse query sleeps a per-warehouse latency_ms to simulate a remote call. Sum of latencies = 400 ms; max = 150 ms. Both backends complete in ~150 ms — the test asserts < 400 ms to catch any regression to serial execution.
-
State machine: switch vs exhaustive match
correctnessA 7-state order lifecycle. The Rust transition table is a match with no catch-all — adding a new variant refuses to compile until handled. The Go switch has no such guarantee; new states silently get no transitions.
-
WebSocket broadcast: hand-rolled hub vs broadcast::Sender
asyncA single ticker per backend mutates an owned price cache and fans out updates to every connected WebSocket subscriber. tokio::sync::broadcast::Sender wraps the fan-out in ~20 lines; the equivalent Go hub is ~50 lines of map + RWMutex + per-client channel.
-
Discriminated unions: serde tag vs json.RawMessage
json modelingOne webhook endpoint, three event shapes (payment.succeeded, payment.failed, refund.created). Rust's serde reads the discriminator and lifts the right variant in one pass; Go has to do a two-step parse via an envelope of json.RawMessage fields.
-
Where Go shines — the honest counter-page
tradeoffsEvery other page on this site argues for Rust. This one doesn't. Five places where Go is the right call — compile times, onboarding cost, deployment artifact, stdlib breadth, and the everyday cost of writing a service.
-
sqlx::query! — the demo that converts skeptics
correctnessDelete a column from your migration and rebuild. sqlx::query! refuses to compile and tells you exactly which call site references the missing column. No tests, no DB roundtrip in production code, no surprise at 3am.
-
Parallel CPU work: rayon::par_iter vs goroutines + WaitGroup
concurrencyGET /products/{slug}/recommendations scores every other product against the target with a CPU-bound similarity hash. Go fans out one goroutine per candidate writing to its own slot in a slice. Rust hands the slice to rayon::par_iter and lets the work-stealing pool spread it across threads.
-
Graceful shutdown: WaitGroup vs JoinHandle + select
concurrencyA periodic background worker that advances paid orders through the fulfillment chain. SIGTERM must stop the loop without aborting an in-flight transition. Go uses context.Context + sync.WaitGroup; Rust uses tokio::select! on a oneshot signal plus an awaited JoinHandle.
-
Rate-limit middleware: chi.Use vs tower::Layer
middleware100 requests per second per IP, fixed window, in memory. The Go side is one function (func(http.Handler) http.Handler) wired in via chi's r.Use. The Rust side is a Layer + a Service impl — two trait impls and a poll_ready dance — for the same outcome.
-
Interfaces, traits, and the cost of mocking
tradeoffsGo developers reach for an interface the moment they want to test something. Rust has three takes on the same shape — concrete, dyn via async_trait, native generics — and none of them is as ergonomic as Go's implicit interfaces. This page makes that comparison directly.