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.
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:
# 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 --versionNew project skeleton:
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
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
#[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:
// 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:
// 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:
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)/bingofmt -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-muslQuick Comparison
| Axis | Go | Rust |
|---|---|---|
| Learning curve | Low-medium | High |
| Concurrency | goroutine + channel | async/await + Send/Sync |
| Error safety | explicit err handling | ownership + Result/Option |
| Deployment | Often one binary; static behavior depends on cgo and dependencies | Target/runtime dependent; musl can enable static linking |
| Ecosystem | strong std lib | broad crates.io |
| Debugging | pprof, trace | gdb/lldb, cargo flamegraph |
| Build time | seconds | minutes (on large projects) |
| Dependency management | go.mod | Cargo.toml + Cargo.lock |
| Cross-compile | GOOS=linux GOARCH=amd64 | --target triple |
When, Which?
| Situation | Choice | Why |
|---|---|---|
| HTTP/JSON API, quick prototype | Go | net/http + single binary, up in 20 minutes |
| Parallel network service, thousands of requests | Go | goroutines are cheap and channel coordination is easy |
| CLI tool, performance-sensitive | Rust | long-lived process with ownership, fast startup |
| Memory safety critical, zero-trust worker | Rust | compile-time guarantee, zero-cost abstraction |
| Deployment tool, single binary | Go | CGO off, static, copy-and-run |
| Kernel/driver/firmware level | Rust | the unsafe boundary is auditable, FFI required |
| Small team, low maintenance burden | Go | small language surface, "one right way" culture |
| High-security parser, fuzzed format | Rust | safe parsing with cargo-fuzz + ownership |
| Both in the same repo | JSON/stdin contract | a 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
- Separate jobs for two languages in CI. Rust:
cargo test,cargo clippy -- -D warnings,cargo fmt --check. Go:go test ./...,go vet ./..., andtest -z "$(gofmt -l .)"so a formatting difference fails the job. - Commit the lock files.
Cargo.lockandgo.sum/go.modshould be tracked; required for a reproducible build. - Try the cross-compile matrix in CI. At least
linux/amd64anddarwin/arm64targets; let the release workflow produce the binaries. - 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. - Dependabot / cargo-audit / govulncheck. Weekly dependency scanning; make
cargo auditandgovulncheck ./...CI steps. - Commit message discipline. With a
feat(alpha):,fix(beta):prefix, show which module changed; small PRs speed up compilation in both languages. - What to watch in code review. In Rust PRs,
unwrap()use andunsafeblocks 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":
# 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 browserIf 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 browserDependency Management
Both ecosystems want safe updates:
# 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.
Related Research & Internal References
- AI CLI Statusline - another tooling-workflow surface.
- Security Logging Design - versioning IPC contracts and keeping checks narrow.
What do you think?
React to show your appreciation