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 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 test ./...cargo testTesting built into the toolchain
go vet ./...cargo clippyLinter; Clippy is more opinionated
gofmt / goimportscargo fmtAuto-formatter, zero config
golangci-lint runcargo clippy -- -D warningsStrict lint mode
go doccargo doc --openGenerate and view API docs
pprofcargo flamegraph / samplyCPU 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."