Pangram verdict · v3.3
We believe that this document is a mix of AI-generated, and human-written content
AI likelihood · overall
MixedArticle text · 1,902 words · 6 segments analyzed
Out of all the migrations I help teams with, Go to Rust is a bit of an outlier. It’s not a question of “is Rust faster?” or “does Rust have types?”, Go already gets you most of the way there. The discussion is mostly about correctness guarantees, runtime tradeoffs, and developer ergonomics. A quick disclaimer before we start: this guide is heavily backend-focused. Backend services are where Go is strongest, small static binaries, a standard library focused on networking, and an ecosystem of libraries for HTTP servers, gRPC, databases, etc. That’s also where most teams considering Rust are coming from (at least the ones who reach out to me), so I think that’s the comparison that’s actually useful in practice. If you’re writing CLI tools, embedded firmware, or game engines, some of this still applies, but to be honest, I’m afraid this is not the best resource for you. For context, I’ve written about Go and Rust before: “Go vs Rust? Choose Go.” back in 2017, and later the “Rust vs Go: A Hands-On Comparison” with the Shuttle team, which walks through a small backend service in both languages. Where Go and Rust overlap, and where they diverge. How Go patterns map to Rust. What you gain from the borrow checker. Where I tell people to keep Go and where Rust is worth the migration cost. How to migrate Go services incrementally. Where I’m Coming From I’ll be upfront: I’m not a fan of Go.
I think it’s a badly designed language, even if a very successful one. It confuses easiness with simplicity, and several of its core design tradeoffs (nil everywhere, error handling as a discipline rule rather than a type, the long absence of generics) point in a direction I disagree with. That said, success matters! Go has captured a real and persistent share of working developers, hovering around 17–19% in the JetBrains Developer Ecosystem Survey. Rust is growing steadily but is still a smaller slice: Go is clearly working for a lot of people, and a guide that pretends otherwise isn’t helpful. So I’ll do my very best to be objective in this guide rather than relitigate old arguments. But you should know my priors so you can calibrate. The other prior worth disclosing: I run a Rust consultancy; of course I’m biased! More people using Rust is good for my business. But I’ve also worked in both languages professionally and shipped Go services to production. This guide is for Go developers who want an honest, side-by-side look at what changes when you move to Rust. For a deliberately opposite take, I recommend reading “Just Fucking Use Go” by Blain Smith. Holding both views in your head at once is more useful than either one alone. If you prefer to watch rather than read, here’s a video from the Shuttle article above, read and commented by the Primeagen: A First Look At The Most Important Commands Go developers already have one of the cleanest toolchains in the industry. Back in the day, it started off a trend of “batteries included” toolchains that give you a single, consistent interface for building, testing, formatting, linting, and managing dependencies. I’m glad that Rust followed suit, because it’s a great model. It’s one of my favorite parts about both ecosystems. cargo has even more built-in: Go toolRust equivalentNotes go.mod / go.sumCargo.toml / Cargo.lockProject config and dependency manifest go get / go mod tidycargo add / cargo updateAdd and resolve dependencies go buildcargo buildCompile the project go run .cargo runBuild and run go test ./...cargo testTesting built into the toolchain go vet ./...cargo clippyLinter, Clippy is significantly more opinionated than vet gofmt / goimportscargo
fmtAuto-formatter, zero config golangci-lint runcargo clippy -- -D warningsStrict lint mode go install ./cmd/foocargo install --path .Install a binary go doccargo doc --openGenerate and view API docs pprofcargo flamegraph / samplyCPU profiling govulncheckcargo auditVulnerability scanning against an advisory database The big difference is that in Go you typically reach for third-party tools (golangci-lint, mockgen, air, goreleaser) to fill gaps. In Rust, the first-party ecosystem covers more out of the box. Things that do require external crates (e.g. cargo watch, cargo nextest) install with one command and feel native, e.g. cargo install cargo-nextest gives you cargo nextest right away. Both communities have converged on the same insight about formatters: a single canonical style, even an imperfect one, is worth more than the bikeshedding it eliminates. Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite. — Rob Pike, Go Proverbs The same is true of rustfmt: not everyone likes every detail, but the absence of style debates in code review is worth far more than the occasional formatting preference you’d have made differently. Key Differences Between Go and Rust GoRust Stable Release20122015 Type SystemStatic, structural, generics since 1.18Static, nominal, generics + traits + lifetimes Memory ManagementGarbage collected (concurrent, low-pause)Ownership and borrowing, no GC Null Safetynil is everywhereNo null; Option<T> is the type-level replacement Error Handlingerror interface, if err != nil { ... }Result<T, E>, ? operator, exhaustive matching ConcurrencyGoroutines + channels (CSP)async/await on tokio + channels + threads Cancellationcontext.Context (convention, not enforced)CancellationToken / explicit, type-checked plumbing Data RacesCaught at runtime via -race (probabilistic, at runtime)Caught at compile time by Send/Sync Compile TimesVery fastSlow, especially clean builds Runtime~2 MB Go runtime + GCNone beyond libc (or fully static
with MUSL) Binary SizeSmall to medium (a few MB)Comparable; very small with panic = "abort" + LTO Learning CurveGentleSteep Ecosystem Size~750k+ modules250,000+ crates The headline is that Go and Rust are both compiled, statically typed, single-binary-deploy languages with strong concurrency stories. The differences are about what guarantees you get from the compiler and how much control you have over runtime behaviour. One framing that helps before we go further: most of what changes when you move from Go to Rust is that checks get pulled into the type system. Nil-handling, error propagation, data races, resource lifetimes, cancellation, generics, these are all things Go relies on convention, tooling (go vet, errcheck, golangci-lint, -race), or runtime detection to keep honest. Rust encodes them as types the compiler enforces directly. The common pushback is that this means “more cognitive overhead.” I’d challenge that. It’s more upfront, yes, but it’s also harder to hold wrong. A Mutex<T> in Rust doesn’t just document that the data needs a lock, it makes the lock the only way to reach the data: you call .lock(), you get a guard, and the guard is what gives you access to the inner value. Drop the guard and the lock releases automatically. There is no “I forgot to lock” path because the unlocked path doesn’t exist in the type. Once you internalize that pattern, and you find it repeated everywhere (Option, Result, &mut T, Send/Sync, RAII guards), Rust stops feeling heavy and starts feeling like the compiler is doing work you used to do in your head. Why Go Developers Consider Rust Go developers don’t usually come to Rust because Go is “too slow.” For most backend workloads, Go is plenty fast. People are generally a bit frustrated with Go’s verbose error handling, the danger of segmentation faults from nil pointers, and the lack of generics (for a long time) or any sophisticated type system features, such as enums or traits. Interfaces are not a worthy replacement for traits, and the Go standard library has some weird gaps, such as the lack of a Set type. (
The idiomatic workaround is map[T]struct{}, which works fine in practice but is a tell that the type system isn’t quite carrying its weight.) nil Panics in Production You ship a Go service, it runs fine for months, and then a code path runs where someone forgot to check whether a pointer was nil, and the goroutine panics. A common case is a lookup that returns the zero value, or a struct whose pointer fields survived deserialization without being populated: func (s *Service) Handle(req *Request) error { // Find returns (*User, error). The error is nil for "not found"; // the caller is expected to check user != nil, but this is very easy to forget. user, err := s.repo.Find(req.UserID) if err != nil { return err } return user.Account.Notify() // crashes if user is nil, or if Account is nil } Linters and IDE checks catch some of these (nilaway, staticcheck), but they’re opt-in, probabilistic, and don’t cross package boundaries reliably. Go’s compiler itself does not force you to consider the absence case. Rust’s Option<T> does: fn handle(&self, req: &Request) -> Result<(), ServiceError> { let user = self.repo.find(req.user_id)?; // returns Option<User>; ? short-circuits None into an error user.notify() } You literally cannot dereference an Option without acknowledging the None case. Whole categories of pager-duty incidents disappear. Data Races That -race Didn’t Catch go test -race is a great tool, but it’s a runtime detector, it only finds races that actually execute during your tests. Mutating a map from two goroutines without a lock compiles fine in Go and only blows up in production under load. In Rust, sharing mutable state across threads requires types that implement Send and Sync. Try to share a plain HashMap between threads and the program does not compile. You’re forced to wrap it in an Arc<Mutex<...>>, an Arc<RwLock<...>>, or use a channel. That race condition becomes a type error. 1 Paul Dix has been very candid about what motivated the InfluxDB 3.0 rewrite, and the data-race story is right at the top: [The main benefit is] fearless concurrency — eliminating data races essentially, which we had before.
Really gnarly bugs in version 1 of Influx due to that. — Paul Dix, Founder & CTO, InfluxData, on Rust in Production Composable Error Handling if err != nil { return err } is fine for a while. After a few years, you notice three things: The boilerplate dilutes the actual logic of your function. Wrapping with fmt.Errorf("doing X: %w", err) is a discipline rule, not a compiler rule. It’s easy to drop context on the floor. Sentinel errors via errors.Is/errors.As work, but the compiler doesn’t tell you when you forgot to handle a new variant. It’s worth being honest about the counter-argument here, since it came up in the Lobste.rs thread on my Shuttle article: experienced Go developers point out that errcheck and golangci-lint catch most of the “forgot to handle the error” cases in practice, and that explicit if err != nil is easier to read than dense ? chains. Both points are fair, and the explicit style is a deliberate cultural value, not an accident: I think that error handling should be explicit, this should be a core value of the language. — Peter Bourgon, GoTime #91, quoted in Dave Cheney’s Zen of Go My take is that lints are an opt-in safety net you have to remember to set up, while Rust’s Result<T, E> is the type signature itself, there’s no way to forget. The boilerplate-vs-readability tradeoff is more genuinely subjective. In Rust: #[derive(Debug, thiserror::Error)] pub enum UserError { #[error("user {0} not found")] NotFound(UserId), #[error("user already exists")] AlreadyExists, #[error(transparent)] Repo(#[from] RepoError), } pub fn rename(id: UserId, name: &str) -> Result<User, UserError> { let mut user = repo::get(id)?; // ? converts RepoError -> UserError automatically user.name = name.to_string(); Ok(user) } The ? operator handles propagation; #[from] handles wrapping; and a match on UserError is exhaustively checked. Add a new variant tomorrow and the compiler shows you every place that needs updating.