Rust and Go: What the Two Together Actually Buy You

Why I use Rust and Go together: the performance-and-safety axis, concurrency, deployment differences, and practical workload division.

10 min read
ibrahimsql
1,957 words

Rust and Go: What the Two Together Actually Buy You

I started with C/C++, tried a lot of languages, and in recent years I've settled on the Rust and Go pair. Both are compiled languages with static type systems; but their solution sets and target audiences differ.

Install

Stand both up at once:

Bash
# Rust - via rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup default stable
rustup component add clippy rustfmt
 
# Go - official package
# macOS
brew install go
# Debian/Ubuntu
sudo apt-get install -y golang-go
# Arch
sudo pacman -S go
 
# Verify
rustc --version   # prints the installed Rust version
go version        # prints the installed Go version
clippy-driver --version

New project skeleton:

Bash
cargo new alpha --bin        # Rust
cd alpha && cargo run
 
go mod init example.com/beta  # Go
# create main.go, then:
go run .

Why Both?

Go's standard library and goroutine model give fast iteration on network services. Rust's ownership and borrow checking give memory safety in safe Rust; it doesn't forbid a logic error, a panic, a deadlock, or resource exhaustion. At unsafe and FFI boundaries the developer carries the preconditions of the guarantees. That's why I tie the language choice not to a "it won't crash" claim but to the workload, the latency budget, and the team's maintenance experience.

Ecosystem and Productivity

A popular language means tooling, a package registry, and CI templates that hundreds of contributors gravitate to. Go's go fmt, go vet, go test, pprof, trace tools all come from one body; you can bring up a binary with net/http and deploy it in 20 minutes.

In Rust the counterpart comes with the cargo and crates.io ecosystem, clippy, rustfmt, and cargo audit. Ready-made crates usually get production-tested, but CFG and version tracking deserve more care when picking a library.

How Do They Divide Work?

In practice my split is this:

  • Go: HTTP/API services, orchestrators, quick prototypes, and deployment tools shipped as a single binary; whether it is fully static depends on the build and dependency graph.

Go's goroutines are descriptive and lightweight for thousands of concurrent requests; in Rust the same thing is done with tokio + async, but often Go's built-in approach can carry the project end to end.

To see the same job in both, here's a parallel-map example:

Go
// Go
package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    jobs := []int{1, 2, 3, 4, 5}
    results := make([]int, len(jobs))
    var wg sync.WaitGroup
    for i, j := range jobs {
        wg.Add(1)
        go func(i, j int) {
            defer wg.Done()
            results[i] = j * j
        }(i, j)
    }
    wg.Wait()
    fmt.Println(results)
}
Rust
// Rust
#[tokio::main]
async fn main() {
    let jobs = vec![1, 2, 3, 4, 5];
    let handles: Vec<_> = jobs
        .into_iter()
        .map(|j| tokio::spawn(async move { j * j }))
        .collect();
    let mut results = Vec::new();
    for h in handles {
        results.push(h.await.unwrap());
    }
    println!("{:?}", results);
}

The Rust example needs an alpha/Cargo.toml dependency:

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

These two examples are not a speed comparison: for five multiplications, the cost of creating tasks can exceed the work. The Tokio task docs explain cooperative scheduling; a long CPU task holds the worker without .await. In CPU-heavy load, think a bounded thread pool/Rayon; in blocking I/O, spawn_blocking; an unbounded task count is not a capacity plan.

The Go example starts five goroutines; WaitGroup keeps the main goroutine waiting until results are ready. The Rust example starts five tasks and waits on the handles in input order. This preserves output order even if completion order differs; cancellation and error propagation are not designed separately in either example.

A Cross-Call (IPC) Example

Rather than embedding both in one project, I have them talk over JSON/stdin. The Rust side reads a list of lines from stdin and produces a sum:

Rust
// alpha/src/main.rs
use std::io::{self, BufRead};
 
fn main() {
    let stdin = io::stdin();
    let mut total: i64 = 0;
    for line in stdin.lock().lines() {
        let line = line.expect("could not read stdin");
        if line.trim().is_empty() { continue; }
        let n: i64 = line.trim().parse().expect("expected a number");
        total += n;
    }
    println!("{{\"sum\": {total}}}");
}

The Go side calls it as a subprocess:

Go
// beta/main.go
package main
 
import (
    "fmt"
    "os/exec"
    "strings"
)
 
func main() {
    cmd := exec.Command("./alpha")
    cmd.Stdin = strings.NewReader("10\n20\n30\n")
    out, err := cmd.Output()
    if err != nil {
        panic(err)
    }
    fmt.Printf("rust said: %s", out)
}

Output: rust said: {"sum": 60}. The contract is simple: feed data line by line to stdin, get a single line of JSON from stdout. On error, Rust should return exit code 1 and Go should catch err.

In the Rust code above, an expect panic produces exit code 101 by default, not 1. On the Go side, distinguish failed exit with *exec.ExitError; keep stderr for the error detail. ./alpha is also relative to the Go process's working directory: cargo build --release produces its output at alpha/target/release/alpha; give that real path when running the example. The input is line-based numbers, and only the output is JSON.

Aliases with a Makefile

Build/test/lint both through the same Makefile:

Makefile
RUST_DIR := alpha
GO_DIR   := beta
 
.PHONY: build test lint run clean release
 
build:
	cd $(RUST_DIR) && cargo build
	cd $(GO_DIR) && mkdir -p bin && go build -o bin/beta .
 
release:
	cd $(RUST_DIR) && cargo build --release
	cd $(GO_DIR) && mkdir -p bin && CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/beta .
 
test:
	cd $(RUST_DIR) && cargo test
	cd $(GO_DIR) && go test ./...
 
lint:
	cd $(RUST_DIR) && cargo clippy -- -D warnings && cargo fmt --check
	cd $(GO_DIR) && go vet ./... && test -z "$$(gofmt -l .)"
 
run:
	cd $(GO_DIR) && go run .
 
clean:
	cd $(RUST_DIR) && cargo clean
	rm -rf $(GO_DIR)/bin

gofmt -l . only prints the names of files that need formatting; it does not make make lint fail by itself. The explicit empty-output check above turns that into a CI failure. CGO_ENABLED=0 disables cgo; for a pure-Go dependency graph it often avoids a libc runtime dependency, but cgo-only packages may no longer build. A Rust musl target can produce a statically linked executable when the target linker and dependencies support it; adding the Rust target alone may not supply the host's complete cross-linking toolchain. Verify the resulting binary on its destination platform rather than inferring static linkage from the command.

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

Quick Comparison

AxisGoRust
Learning curveLow-mediumHigh
Concurrencygoroutine + channelasync/await + Send/Sync
Error safetyexplicit err handlingownership + Result/Option
DeploymentOften one binary; static behavior depends on cgo and dependenciesTarget/runtime dependent; musl can enable static linking
Ecosystemstrong std libbroad crates.io
Debuggingpprof, tracegdb/lldb, cargo flamegraph
Build timesecondsminutes (on large projects)
Dependency managementgo.modCargo.toml + Cargo.lock
Cross-compileGOOS=linux GOARCH=amd64--target triple

When, Which?

SituationChoiceWhy
HTTP/JSON API, quick prototypeGonet/http + single binary, up in 20 minutes
Parallel network service, thousands of requestsGogoroutines are cheap and channel coordination is easy
CLI tool, performance-sensitiveRustlong-lived process with ownership, fast startup
Memory safety critical, zero-trust workerRustcompile-time guarantee, zero-cost abstraction
Deployment tool, single binaryGoCGO off, static, copy-and-run
Kernel/driver/firmware levelRustthe unsafe boundary is auditable, FFI required
Small team, low maintenance burdenGosmall language surface, "one right way" culture
High-security parser, fuzzed formatRustsafe parsing with cargo-fuzz + ownership
Both in the same repoJSON/stdin contracta module per language, no single-binary bloat

Simple rule: if the service side is network work, Go; if the binary side is security/performance, Rust. Don't embed both in one process; keep the interface clear.

PR Workflow Notes

  1. Separate jobs for two languages in CI. Rust: cargo test, cargo clippy -- -D warnings, cargo fmt --check. Go: go test ./..., go vet ./..., and test -z "$(gofmt -l .)" so a formatting difference fails the job.
  2. Commit the lock files. Cargo.lock and go.sum/go.mod should be tracked; required for a reproducible build.
  3. Try the cross-compile matrix in CI. At least linux/amd64 and darwin/arm64 targets; let the release workflow produce the binaries.
  4. Version the IPC contract. Keep the Rust↔Go JSON schema in the repo as schema.json; validate on both sides. Label schema changes as breaking.
  5. Dependabot / cargo-audit / govulncheck. Weekly dependency scanning; make cargo audit and govulncheck ./... CI steps.
  6. Commit message discipline. With a feat(alpha):, fix(beta): prefix, show which module changed; small PRs speed up compilation in both languages.
  7. What to watch in code review. In Rust PRs, unwrap() use and unsafe blocks ask for a comment; in Go PRs, goroutine leaks (forgotten WaitGroup) and context cancellation slip through.

Profiling and Debugging

Both languages have profiling tools that work in production; measure instead of guessing "it's slow":

Bash
# Go - CPU profile
go test -bench=. -cpuprofile=cpu.prof
go tool pprof cpu.prof
# inside pprof: top, list main.main, web (graphviz required)
 
# Go - goroutine and heap
pprof -http=:8080 http://localhost:6060/debug/pprof/heap
 
# Rust - flamegraph script
cargo install flamegraph
cargo flamegraph --bin alpha
# produces perf.data; open the SVG flamegraph in a browser

If you run an HTTP service in Go, import _ "net/http/pprof" and attach it to the default mux; take a live profile from the /debug/pprof/ endpoints. On the Rust side you can do the same with pprof-rs or tracing + tracing-flame, but setup is one extra step.

For a trace-level timeline:

go test -trace=trace.out
go tool trace trace.out
# a goroutine timeline opens in the browser

Dependency Management

Both ecosystems want safe updates:

Bash
# Rust
cargo install cargo-audit
cargo audit                      # known CVE scan
cargo update                     # semver-compatible updates
cargo outdated                   # (cargo-outdated) old crates
 
# Go
go list -m -u all                # updatable modules
go get -u=patch ./...            # patch upgrades only
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

Add a cargo audit and govulncheck ./... step to CI; have weekly dependabot or renovate open PRs automatically. Cargo.lock and go.sum should be tracked - without a lock file, the same commit behaves differently on two machines.

On major upgrades (Go 1.x to 1.y, an edition change in Rust), first build in a separate branch, test, then merge.

The Important Part: Commitment and Plumbing

Choosing a language is more than a technical decision: to stick with the same language on a long-lived project, you need to like it. My reason for liking Go is simplicity and a fast deploy loop; my reason for liking Rust is the comfort on error safety. Still, I don't embed both in one project: each language's consistency stays within itself, and they talk over JSON/stdin when needed.

Collecting the cargo and go targets in a single Makefile from day one ends the "which command do I run" debate. The make build && make test && make lint trio is the same every day. When someone new joins, those three lines in the README are enough.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts