The Core Tradeoff: Compile-Time vs. Runtime Safety
Go developers rarely move to Rust because Go is too slow. For most backend workloads, Go is plenty fast. The motivation is correctness. Go relies on convention, linters, and runtime detection (e.g., -race, nilaway) to catch nil dereferences, data races, and error handling gaps. Rust encodes these guarantees in the type system, enforced at compile time.
As the author of the guide (a Rust consultant who has shipped Go to production) puts it: "Most of what changes when you move from Go to Rust is that checks get pulled into the type system." This means more upfront cognitive load, but also fewer production incidents.
Where Go and Rust Overlap
Both are compiled, statically typed, single-binary-deploy languages with strong concurrency stories. Their toolchains share a similar "batteries included" philosophy:
| Go tool | Rust equivalent | Notes |
|---|---|---|
go.mod / go.sum | Cargo.toml / Cargo.lock | Project config and dependency manifest |
go get / go mod tidy | cargo add / cargo update | Add and resolve dependencies |
go build | cargo build | Compile the project |
go test ./... | cargo test | Testing built into the toolchain |
go vet ./... | cargo clippy | Linter; Clippy is more opinionated |
gofmt / goimports | cargo fmt | Auto-formatter, zero config |
golangci-lint run | cargo clippy -- -D warnings | Strict lint mode |
go doc | cargo doc --open | Generate and view API docs |
pprof | cargo flamegraph / samply | CPU profiling |
Rust's first-party tooling covers more out of the box. In Go, developers often reach for third-party tools like golangci-lint, mockgen, or air to fill gaps.
Three Concrete Gains
1. Nil Safety
A common Go bug:
func (s *Service) Handle(req *Request) error {
user, err := s.repo.Find(req.UserID)
if err != nil {
return err
}
return user.Account.Notify() // crashes if user or Account is nil
}
Linters can catch some of these, but they're opt-in and probabilistic. Rust's Option makes the absence case explicit:
fn handle(&self, req: &Request) -> Result<(), ServiceError> {
let user = self.repo.find(req.user_id)?; // ? short-circuits None
user.notify()
}
You cannot dereference an Option without acknowledging the None case. Whole categories of pager-duty incidents disappear.
2. Data Race Prevention
Go's -race flag is a runtime detector; it only finds races that execute during tests. Mutating a map from two goroutines without a lock compiles fine and may only blow up under load.
In Rust, sharing mutable state across threads requires types that implement Send and Sync. A plain HashMap shared between threads won't compile. You're forced to wrap it in Arc> or use a channel. Paul Dix of InfluxData cited this as a key motivator for the InfluxDB 3.0 rewrite: "Eliminating data races — essentially, which we had before. Really gnarly bugs in version 1 of Influx due to that."
3. Composable Error Handling
Go's if err != nil { return err } pattern is explicit but verbose. Wrapping with fmt.Errorf("doing X: %w", err) is a discipline rule, not a compiler rule. Rust's Result and the ? operator make error propagation concise and exhaustive:
#[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 {
let mut user = repo::get(id)?; // ? converts RepoError -> UserError automatically
user.name = name.to_string();
Ok(user)
}
Add a new variant to UserError and the compiler shows every place that needs updating.
What You Give Up
- Compile times: Rust's clean builds are slow. Go's are very fast.
- Learning curve: Go is gentle; Rust is steep.
- Runtime: Go ships a ~2 MB runtime (GC, scheduler). Rust has no runtime beyond libc; binaries can be very small with
panic = "abort"+ LTO. - Ecosystem: Go has ~750k+ modules; Rust has ~250k+ crates.
When to Stay with Go
The guide advises keeping Go for:
- Teams that value fast compile times and low onboarding overhead.
- Applications where nil panics or data races have been manageable with linters and testing.
- Projects that don't need fine-grained control over memory or async runtime.
Incremental Migration Strategy
The guide recommends migrating Go services incrementally: start with a new microservice in Rust, or rewrite a performance-critical component. Use FFI (Foreign Function Interface) to let Rust and Go coexist during the transition.
The Author's Bias
The author runs a Rust consultancy, so they're biased. They also share a link to "Just Fucking Use Go" by Blain Smith for the opposite view. "Holding both views in your head at once is more useful than either one alone."


