2026-05-21 08:00:00

Hot off the press: this episode is a live recording from Rust Week in Utrecht, just two days ago. On stage with me are two people who hardly need an introduction in the Linux world: Greg Kroah-Hartman, Linux Foundation Fellow, stable kernel maintainer and an embassador for the kernel, and Alice Ryhl, core maintainer of Tokio and one of the driving forces behind Rust for Linux at Google.
I have to admit a bit of personal history here: I first wrote about Greg more than 20 years ago for the German online newspaper Pro-Linux. Getting to sit down with him, and with Alice, in front of a live audience to talk about how Rust is reshaping the most important piece of infrastructure on the planet, was a genuine career highlight.
We get into the big questions: Why does Alice believe that interop, not rewrites, is how Rust wins inside Linux? How do you carefully weave in Rust while maintaining a 35-million-line C codebase? And what does it actually feel like, day to day, to write kernel code in Rust?
“Rust is gonna save the Linux kernel.” — Greg Kroah-Hartman
CodeCrafters helps you become proficient in Rust by building real-world, production-grade projects. Learn hands-on by creating your own shell, HTTP server, Redis, Kafka, Git, SQLite, or DNS service from scratch.
Start for free today and enjoy 40% off any paid plan by using this link.
Rust for Linux is the project bringing the Rust programming language into the Linux kernel. After years of patches, proposals, and heated mailing list threads, Rust is now an officially supported language inside the kernel tree, no longer an experiment. The work spans everything from the build system and the kernel crate to drivers, abstractions over core subsystems and brand-new pieces of infrastructure written entirely in Rust.
Greg Kroah-Hartman is a Linux Foundation Fellow, the maintainer of the stable Linux kernel branch, and the maintainer of, among many other things, the USB subsystem, the driver core, sysfs, debugfs, kobject, TTY layer and staging tree. He has been a central figure in Linux for over two decades, has written several books about kernel development, and is convinced Rust belongs in the kernel.
Alice Ryhl is a software engineer at Google working on Android and Rust for Linux, and a core maintainer of Tokio, the asynchronous runtime that over 50% of all crates on crates.io directly depends on. Inside the kernel she works on Binder, on async abstractions, and on the bindings that allow Rust drivers to talk safely to the rest of the kernel.
Rust Week is an annual conference organized by RustNL. The 2026 edition took place in Utrecht, the Netherlands, from May 18 to May 23. It features talks, workshops, the Rust All Hands, and expert sessions on a wide variety of topics revolving around Rust. This episode was recorded live on stage during the conference. Thanks to the Rust Week team who made this recording possible! Learn more about Rust Week on their website.
2026-05-21 08:00:00
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.
What you will learn in this article
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:
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 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 run . |
cargo run |
Build and run |
go test ./... |
cargo test |
Testing built into the toolchain |
go vet ./... |
cargo clippy |
Linter, Clippy is significantly more opinionated than vet
|
gofmt / goimports
|
cargo fmt |
Auto-formatter, zero config |
golangci-lint run |
cargo clippy -- -D warnings |
Strict lint mode |
go install ./cmd/foo |
cargo install --path . |
Install a binary |
go doc |
cargo doc --open |
Generate and view API docs |
pprof |
cargo flamegraph / samply
|
CPU profiling |
govulncheck |
cargo audit |
Vulnerability 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.
| Go | Rust | |
|---|---|---|
| Stable Release | 2012 | 2015 |
| Type System | Static, structural, generics since 1.18 | Static, nominal, generics + traits + lifetimes |
| Memory Management | Garbage collected (concurrent, low-pause) | Ownership and borrowing, no GC |
| Null Safety |
nil is everywhere |
No null; Option<T> is the type-level replacement |
| Error Handling |
error interface, if err != nil { ... }
|
Result<T, E>, ? operator, exhaustive matching |
| Concurrency | Goroutines + channels (CSP) |
async/await on tokio + channels + threads |
| Cancellation |
context.Context (convention, not enforced) |
CancellationToken / explicit, type-checked plumbing |
| Data Races | Caught at runtime via -race (probabilistic, at runtime) |
Caught at compile time by Send/Sync
|
| Compile Times | Very fast | Slow, especially clean builds |
| Runtime | ~2 MB Go runtime + GC | None beyond libc (or fully static with MUSL) |
| Binary Size | Small to medium (a few MB) | Comparable; very small with panic = "abort" + LTO |
| Learning Curve | Gentle | Steep |
| Ecosystem Size | ~750k+ modules | 250,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.
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 ProductionYou 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.
-race Didn’t Catchgo 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
if err != nil { return err } is fine for a while.
After a few years, you notice three things:
fmt.Errorf("doing X: %w", err) is a discipline rule, not a compiler rule. It’s easy to drop context on the floor.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.
Go got generics in 1.18, and they’re useful, but the implementation has constraints (no methods with type parameters, GC shape stenciling, occasional surprising performance characteristics). Rust generics monomorphize, each instantiation produces specialized code with zero runtime cost. Combined with traits, this gives you real zero-cost abstractions.
This matters less in handler code and more in shared infrastructure (middleware, generic repositories, decoders, parsers), where Go often pushes you back to interface{}/any plus type assertions.
Go’s GC is excellent, concurrent, low-pause, well-tuned for typical service workloads. But “low-pause” is not “no-pause.” Under heavy allocation, P99 latency tails are noticeably worse than a Rust equivalent that simply doesn’t allocate on the hot path.
I won’t oversell this, for the vast majority of services, Go’s GC is a non-issue. But for latency-sensitive systems (trading, real-time bidding, network proxies, high-throughput ingestion), the lack of GC pauses is a genuine selling point. Stephen Blum from PubNub put it directly on the show:
Go is great at our scale, but we really need something that is going to give us the price-per-dollar performance capacity that we need, and Rust is going to get us there. That’s why basically everything is heading towards Rust these days.
— Stephen Blum, CTO, PubNub, on Rust in Production
Go is death by a thousand paper cuts. It is a very pragmatic language and if you are willing to glance over the above issues, you can be very productive in it. But at a certain codebase size, the problems start to compound. There is no single moment when Go loses its appeal, but teams find themselves wishing for more (more safety, more control, more expressiveness) and that’s when they start looking around for alternatives.
The fastest way to feel comfortable in Rust is to map patterns you already know. For a longer, fully-worked example of building the same backend service in both languages, see the Shuttle comparison, the section below focuses on the patterns that come up most often.
if err != nil vs Result<T, E>Go:
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
Rust:
fn read_config(path: &Path) -> Result<Config, ConfigError> {
let data = fs::read_to_string(path)?;
let cfg = serde_json::from_str(&data)?;
Ok(cfg)
}
The ? operator does the if err != nil { return err } dance for you, including type conversion if From<E1> for E2 is implemented (idiomatic with thiserror’s #[from]).
nil vs Option<T>Go:
func GetUser(id string) *User {
for _, u := range users {
if u.ID == id {
return &u
}
}
return nil
}
u := GetUser("123")
fmt.Println(u.Name) // panics if nil
Rust:
fn get_user(id: &str) -> Option<User> {
users.iter().find(|u| u.id == id).cloned()
}
let user = get_user("123");
println!("{}", user.name); // compile error: `user` is Option<User>, not User
// You must handle both cases:
match get_user("123") {
Some(u) => println!("{}", u.name),
None => println!("not found"),
}
There is no nil in safe Rust. References can’t be null. Pointers can be, but you almost never use raw pointers in application code.
Go’s interfaces are structural, a type satisfies an interface implicitly:
type Reader interface {
Read(p []byte) (n int, err error)
}
Rust’s traits are nominal, you implement them explicitly:
pub trait Reader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
}
impl Reader for MyType {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { /* ... */ }
}
The Go style is great for ad-hoc duck typing. The Rust style is great for refactoring and discoverability, you can grep for every implementer of a trait.
The closest equivalent of interface{} / any in Rust is Box<dyn Any>, but you almost never want it. The Go community knows the cost of reaching for interface{} too:
interface{} says nothing.
— Rob Pike, Go Proverbs
Generic functions with trait bounds (fn handle<R: Reader>(r: R)) cover the vast majority of cases and give you monomorphization with no runtime dispatch. Where Go pre-1.18 would have forced you back to interface{} plus a type assertion, Rust’s traits + generics let you stay specific.
When you do want runtime dispatch (e.g. heterogeneous storage of different implementers), reach for Box<dyn Trait> or Arc<dyn Trait>. That’s the direct Rust analog of holding an interface value in Go.
Go’s concurrency model is famously simple:
go doWork(ctx, input)
Goroutines are cheap, the runtime schedules them across OS threads, and channels (chan T) are the primary coordination primitive. The Go proverb captures the philosophy:
Don’t communicate by sharing memory; share memory by communicating.
— Rob Pike, Go Proverbs
This is the area where Go genuinely shines, and it’s worth being precise about why: in Go there is no syntactic distinction between sequential and parallel code. Any function can be called normally, dropped into a go statement, or invoked from inside a goroutine, without changing its signature, its callers, or anything about how it’s written. There is no async fn, no .await, no executor to pick, no Send/Sync bound to satisfy. As long as you don’t share mutable state without synchronization, sequential and concurrent code look identical.
That property, the absence of function colouring, is the single biggest day-to-day productivity win Go has over Rust, and it’s the thing Go developers miss most after switching. Several commenters in the Lobste.rs discussion of my Shuttle article made exactly this point, and they’re right. Rust async is more powerful and more checked, but it is also more explicit in your code, and that visibility has a real ergonomic cost.
Rust uses async/await on top of an executor (almost always tokio for backend services):
tokio::spawn(async move {
do_work(input).await;
});
The shape is similar. The differences:
Futures. They don’t run until awaited or spawned.Send/Sync across .await points. If you hold a non-Send value across an await, you get a compile error explaining exactly why.tokio::task::spawn_blocking or rayon instead.tokio::sync::mpsc, broadcast, watch) are first-class but live in libraries, not the language.For most backend code, the day-to-day feel is similar: spawn a task, communicate via channels, use timeouts liberally.
context.Context vs CancellationTokenIn Go, you plumb a context.Context through every blocking call:
func (s *Service) Fetch(ctx context.Context, id string) (*User, error) {
return s.client.Get(ctx, "/users/"+id)
}
Rust has no built-in context.Context. The closest equivalent for cancellation is tokio_util::sync::CancellationToken:
pub async fn fetch(&self, token: CancellationToken, id: &str) -> Result<User, FetchError> {
tokio::select! {
_ = token.cancelled() => Err(FetchError::Cancelled),
res = self.client.get(&format!("/users/{id}")) => res,
}
}
For timeouts, tokio::time::timeout(dur, fut) wraps any future.
For deadlines/values, you typically pass them as explicit arguments or via tracing spans rather than a single context object.
Some Go developers miss the implicit-feel of ctx. In practice, the explicit Rust style is easier to reason about, you always know exactly what’s cancellable and what isn’t. The deeper point is that neither language gives you cancellation for free, the discipline just shows up at different layers:
Go doesn’t have a way to tell a goroutine to exit. There is no stop or kill function, for good reason. If we cannot command a goroutine to stop, we must instead ask it, politely.
— Dave Cheney, The Zen of Go
In Go that “asking politely” is a context.Context plumbed through every call site by convention. In Rust it’s a CancellationToken (or a watch channel) plumbed through every call site, but the compiler can actually tell you when you forgot.
Both languages have channels. The translation is direct:
ch := make(chan int, 10)
go func() {
ch <- 42
}()
v := <-ch
let (tx, mut rx) = tokio::sync::mpsc::channel::<i32>(10);
tokio::spawn(async move {
tx.send(42).await.unwrap();
});
let v = rx.recv().await.unwrap();
Rust’s channels distinguish sender and receiver as separate types, which makes ownership and Send-ness explicit at the type level.
Go:
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
Rust:
pub struct Circle {
pub radius: f64,
}
impl Circle {
pub fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
Rust’s &self is the equivalent of a Go value receiver; &mut self is a pointer receiver with mutation. Owned self (consuming the value) has no Go analog and is occasionally very useful (typestate, builders).
string vs String and &strGo’s string is a UTF-8 byte slice with copy-on-assign semantics (the header is copied, the underlying bytes are shared and immutable).2
Rust splits this into two types:
String, owned, heap-allocated, growable. Equivalent to []byte you intend to mutate.&str, a borrowed view into someone else’s string data. Equivalent to a Go string parameter most of the time.As a rule of thumb, take &str in arguments, return String when you produce new data.
fn greet(name: &str) -> String {
format!("Hello, {name}")
}
This is mostly painless once you internalize it. The &str vs String split is a microcosm of Rust’s broader “borrow vs own” model.
Go got generics in 1.18 (March 2022), thirteen years after the language shipped. They are useful, but they feel tacked on, and in practice they have most of the downsides of a generic type system without delivering the upsides you’d expect coming from Rust, Haskell, or even modern C++.
This is a strong claim, so let me back it up.
The most telling signal is that three years after generics landed, Go’s own standard library still mostly avoids them.
sort.Slice still takes a func(i, j int) bool closure instead of a cmp.Ordered constraint.
sync.Map is still typed as any/any.
The generic helpers that do exist live in a small handful of packages: slices, maps, cmp, and a few entries under sync.
It’s fair to point out that backwards compatibility is part of the story here: the Go 1 compatibility promise means the existing non-generic APIs can’t be retrofitted, so any generic version has to live alongside them (or in a new package). But that’s only part of the explanation. Three years is plenty of time to introduce generic alternatives, and the fact that very few have appeared suggests the language designers don’t lean on generics as a primary tool the way Rust does.
Compare that to Rust, where generics permeate the standard library from day one: Option<T>, Result<T, E>, Vec<T>, HashMap<K, V>, Iterator, From/Into, AsRef, Borrow, every collection, every smart pointer.
You cannot write idiomatic Rust without using generics, because the standard library is generic.
In Go, generics are an opt-in feature for library authors who really need them. In Rust, they’re the substrate everything else is built on.
Rust’s generics are tied to traits, which double as the language’s mechanism for ad-hoc polymorphism, supertraits, associated types, blanket impls, and coherence.
Go’s constraints are just interfaces with an extra ~ operator for type-set membership. There are no:
trait Ord: Eq + PartialOrd, and any T: Ord automatically satisfies Eq and PartialOrd. Go has no equivalent; you stack interface embeddings, but the constraint solver doesn’t reason about hierarchies the way Rust’s trait system does.Iterator has type Item;, so T::Item is a first-class thing you can name in bounds. Go’s closest equivalent is a second type parameter, which leaks into every signature.impl<T: Display> ToString for T automatically gives every Display type a to_string() method. Go has no way to add methods to a type from outside its defining package, generic or not.func (s Set[T]) Map[U](f func(T) U) Set[U]3. In Rust, generic methods on generic types are routine.The practical consequence is that the moment your abstraction needs more than “a function that works for any T with these few operations,” Go pushes you back to any plus type assertions, code generation, or runtime reflection.
Rust uses a Hindley-Milner-style inference engine that propagates type information through entire expressions, including across closures, iterator chains, and ? operators. You routinely write:
let evens: Vec<_> = (0..100).filter(|n| n % 2 == 0).collect();
and the compiler figures out _ is i32 from the range, and Vec<_> is Vec<i32> from the collect target.4
Go’s inference is much shallower. It can usually infer type parameters from function arguments, but it cannot infer from return-position context, cannot chain inference through generic builders the way Rust does, and frequently forces explicit type arguments at call sites:
result := slices.Collect[int](iter) // often required
In Rust this is the exception; in Go it’s still common.
There’s no free lunch with generics: you either pay at compile time, at runtime, or you give up specialization (more on that in a bit). C++ and Rust pay at compile time through monomorphization. Java pays at runtime through type erasure plus the JIT. Go picked a middle path with GCShape stenciling and dictionaries: types that share a “GC shape” share the same compiled function and dispatch through a runtime dictionary.
The Go choice keeps compile times fast, which is a real and valuable property. The cost is that generic Go code can be measurably slower than the equivalent hand-written non-generic version, because every method call on a type parameter goes through an indirection. There’s a well-known PlanetScale post showing exactly this.
Rust monomorphizes: every Vec<i32> and Vec<String> produces specialized machine code with zero runtime dispatch. Generic code is the fast path, and reaching for dyn Trait (the equivalent of Go’s interface dispatch) is a deliberate choice you make when you want runtime polymorphism. You pay for monomorphization with compile times, which is the same bill C++ has been paying for decades. Neither tradeoff is obviously right; they just optimize for different things.
This is the part that bothers me most.
A good generics system removes reasons to fall back to escape hatches. In Rust, generics + traits eliminate most of what you’d otherwise need Box<dyn Any> or runtime reflection for. The type system gets stronger.
In Go, generics did not remove any, did not remove reflect, did not remove code generation as the dominant pattern for things like ORMs, decoders, and mocks. encoding/json still uses reflection. database/sql still uses any. mockgen still generates code. The places where a real generics system would shine are the same places Go reaches for runtime mechanisms it had before 1.18.
Generics in Go feel additive, a new tool in the box that’s useful in narrow cases. Generics in Rust feel foundational; remove them and the language collapses.
That’s the difference, and it’s why generic Go code, in my experience, doesn’t read better than the interface{}-based code it replaced; it just reads differently, with more punctuation.
| Concern | Go | Rust |
|---|---|---|
| HTTP server |
net/http, chi, gin, echo, fiber
|
axum (on hyper) |
| HTTP client |
net/http, resty
|
reqwest |
| gRPC |
google.golang.org/grpc + protoc-gen-go
|
tonic + prost
|
| OpenAPI (codegen) | oapi-codegen |
utoipa (code-first) or openapi-generator
|
| SQL |
database/sql, sqlc, sqlx, gorm
|
sqlx, sea-orm, diesel
|
| Migrations |
golang-migrate, goose
|
sqlx migrate, refinery
|
| JSON |
encoding/json, sonic, goccy/go-json
|
serde + serde_json
|
| Logging |
log/slog, zerolog, zap
|
tracing + tracing-subscriber
|
| Metrics | prometheus/client_golang |
metrics + metrics-exporter-prometheus
|
| Config |
viper, koanf
|
config (config-rs), figment
|
| CLI |
cobra, urfave/cli
|
clap (derive) |
| Validation | go-playground/validator |
validator |
| Errors |
errors, pkg/errors
|
thiserror (libraries), anyhow (binaries) |
| Testing |
testing, testify, gomega
|
built-in #[test], rstest, assert_matches
|
| Mocking |
mockgen, moq
|
hand-written fakes (idiomatic), mockall
|
| HTTP mocking | httptest |
httpmock, wiremock-rs
|
| Real deps in tests | testcontainers-go |
testcontainers |
| Retry/backoff | cenkalti/backoff |
backon |
| Background tasks | goroutines + errgroup
|
tokio::spawn + JoinSet
|
If you’re already opinionated in Go, the Rust ecosystem has converged to a similar level of “default picks.” For a typical backend service: axum + sqlx + tokio + tracing + serde + clap covers 90% of what you need.
I want to be straightforward here. Coming from Go, you will hit a wall. The wall has a name.
Go’s runtime handles memory and aliasing for you. Rust pushes that decision into the type system. The first few weeks you’ll write code that “should obviously work” and the compiler will refuse it.
The patterns that bite Go developers most often:
*User from a map for as long as you want. In Rust, that borrow blocks mutation of the map for its whole lifetime. The fix is usually to clone, or to scope the borrow tighter.Pin, ouroboros, or a redesign. Almost always: redesign.mu sync.Mutex; data map[K]V becomes Arc<Mutex<HashMap<K, V>>>. Slightly more verbose, much more checked.With all of these rules, the borrow checker truly sounds like a “gatekeeper” of sorts, which keeps getting in the way and is just overall frustrating to deal with.
That is not the mental mindset you should have when learning Rust.
The borrow checker truly uncovers real and very existing bugs in your code, and if you don’t address them, your program will deal with safety issues.
So whenever you get a compiler error from rustc, take a step back and think how your code could break.
A few questions you can ask yourself:
That is the mindset you need to understand the borrow checker.
Humans are genuinely bad at reasoning about memory.
We forget that pointers can be null, that old references can outlive the data they point to, and that multiple threads can touch the same data at the same time.
We tend to have a “linear” mental model of how data flows through a program, but in reality it’s closer to a complex graph with many paths and interactions.
Every if condition forces you to consider what happens in both branches.
Every loop forces you to consider what happens on every iteration.
That is exactly the kind of reasoning the borrow checker is designed to do for you!
It enforces best practices at compile time, and it can feel annoying when your own mental model disagrees with the borrow checker’s (which is the more accurate one 99% of the time).
There are cases where the borrow checker is genuinely too strict, but they are rare, and as a beginner you’ll almost never run into them.
I got memory management wrong plenty of times in my early days, but I approached it with a learner’s mindset, which helped me ask “what’s wrong with my code?” instead of “what’s wrong with the compiler?”, a reaction I see a lot in trainings.
The good news is that once you internalize borrowing, it stops fighting you. Most experienced Rust developers will tell you the borrow checker became an ally somewhere between weeks 4 and 12. The first month is the hardest. Stephen Blum from PubNub described that first month well on a recent podcast:
When you started to get into it: frustration. It reminded me of what it was like to learn programming for the first time, because it’s so different. With the borrow checker and lifetimes, I didn’t want to have to deal with those things — but I was forced to.
— Stephen Blum, CTO, PubNub, on Rustacean Station
And here’s Ed Page (maintainer of clap) on the other side of that curve, which is what you should be optimizing for:
The borrow checker has saved me from having to think about these problems, and instead I’m able to focus on higher-level problems. It’s helped catch things when I’ve done my own analysis and failed at it.
— Ed Page, on Rustacean Station: clap with Ed Page
Be honest with your team, Rust compile times are a real downgrade from Go’s.
A clean release build of a medium service can take minutes in comparison to Go’s near-instantaneous compiles.
Incremental builds and cargo check are reasonable and compile times have gotten much better over the years, but you’ll feel the difference.
To mitigate, use cargo check in your edit loop, split into a workspace once it pays off, and keep proc-macro-heavy crates in their own crate so they only recompile when they change.
See tips for faster Rust compile times for a deeper dive.
As covered in Goroutines vs Async Tasks, Rust’s async fn / fn split is one of the biggest ergonomic regressions coming from Go. Async traits have been stable since Rust 1.75, but there are still rough edges around mixing them with dynamic dispatch, occasionally you’ll reach for the async-trait crate to paper over them.
Rust’s crate ecosystem is growing and libraries are high-quality across the board, but Go has a head start in some backend-adjacent domains: Kubernetes operators, cloud-provider SDKs, database drivers for certain niche stores. Before you commit, spend a day checking that the libraries you depend on have Rust equivalents you’re willing to use. Teams I help often have to hand-roll at least one or two core libraries themselves. For example, they might have to update an abandoned crate for XML schema validation, or write their own client for a lesser-known protocol.
You don’t have to rewrite everything in one go. Every successful Go-to-Rust story I’ve heard on the podcast is tactical, not big-bang. Victor Ciura from Microsoft put it well:
We’re not madly going across the board and just, for the fun of it, rewriting everything in Rust. We’re doing these tactical choices where we say: okay, this new component, it’s better if we [do it in Rust].
— Victor Ciura, Principal Engineer, Microsoft, on Rust in Production
The strategies that work best, in order of how I usually recommend them:
If one specific service in your fleet is the perpetual problem child (high CPU, latency-sensitive, or constantly hit with reliability issues), rewrite just that one in Rust, behind the same API contract. This is the lowest-risk migration. Other Go services keep talking to it via HTTP/gRPC, oblivious to the underlying language. Jeff Kao at Radar described how Discord’s famous post is often the spark that gives teams permission to even try:
If you go on Hacker News and look up “migrating to Rust,” the first result is always this one about Discord moving from Go to Rust. It almost motivated us to see [if we could do the same].
— Jeff Kao, CTO, Radar, on Rust in Production
Background workers, queue consumers, ingestion pipelines, and CPU-bound batch jobs are excellent first targets. They typically have a clear input/output boundary (a queue, a topic) and no shared in-process state with the rest of the system.
You can call Rust from Go via cgo, and there are good guides on how to do it. (Reach out if you’d be interested in a guide on this from me.) In practice, I rarely recommend it for backend services. The build complexity and FFI overhead usually outweigh the benefits compared to “just stand up a Rust service and put it behind a network call.” For libraries and CLI tools, it’s more viable.
If you have an API gateway or reverse proxy, you can route specific endpoints to a new Rust service while the rest stays in Go. This works particularly well when one bounded context (auth, search, billing) is the right unit to migrate. The pattern is often called “strangler fig,” because the new service grows around the old one until it eventually replaces it entirely.
Start with a service that has a clear boundary. Don’t pick the most central, most-deployed service in your fleet. Pick the one where the contract with the rest of the system is well-defined and the blast radius is small.
Keep the same API contract. If your Go service exposes a REST API, your Rust service should too: same paths, same JSON shapes, same error envelope. The migration is invisible to clients, and you can swap traffic incrementally with a gateway.
Don’t translate idioms verbatim. Resist the urge to write Go-flavoured Rust. if err != nil { return err } becomes ?. Goroutine-per-request becomes tokio::spawn only when you actually need it (axum already concurrently handles requests). Interfaces with one method usually become trait bounds on a generic, not Box<dyn Trait>.
Use the compiler as a pair programmer. Rust’s compiler errors are usually pretty good. Read them slowly. They almost always tell you the right answer. The team members who struggle longest are the ones who fight the compiler instead of treating it as a collaborator.
Invest in training early. I’ve seen teams try to do a Rust migration “on the side,” learning as they go. It rarely ends well. It’s a bit like training for a marathon by signing up for the race and then trying to run it without any prior training. You can do it, but it’s going to be painful and you might not finish. Block off real time for learning: a workshop, an online course, paired sessions on real code. The upfront investment pays back many times over once the team is fluent. (Hey, if you want to talk about training options, I’m happy to chat.)
Not everything should be migrated. Go is excellent for:
This is not a niche position. It lands harder coming from a company that ships both languages at scale:
Go is a very fine choice for networking services. We have a lot of Go at Canonical — Juju is a huge Go codebase.
— Jon Seager, VP of Engineering, Canonical, on Rust in Production
A hybrid strategy is fine and common. Many of the teams I work with end up with a polyglot backend: Go for the “boring” services, Rust for the ones where reliability and performance pay back the extra effort.
Numbers vary wildly by workload, so take these as rough guidance. Not promises! But here are some ballpark numbers, based on Go-to-Rust migrations I’ve helped with:
CPU usage: 20–60% reduction. Less dramatic than Python-to-Rust, because Go is already efficient. The wins come from no GC and tighter loops.
Memory: 30–50% reduction, mostly from the absence of GC overhead and a smaller runtime.
P99 latency: significantly more consistent. Rust services tend to flatline where Go services have visible GC-induced jitter. (This has gotten much better on the Go-side ever since they introduced their low-latency GC, but the difference is still there under heavy load.)
Production incidents: this is the one teams report most enthusiastically. The classes of bugs that survive go test -race and reach production (data races, nil dereferences, missed error paths) just don’t compile in Rust. Oncall rotations are typically very boring after a Rust migration. Andrew Lamb described exactly this effect after the InfluxDB rewrite:
I hadn’t had to chase down a crash, or some weird multi-threaded race condition, or some of these other things which actually consumed a huge amount of my time before.
— Andrew Lamb, Staff Engineer, InfluxData, on Rustacean Station: Rebuilding InfluxDB with Rust
Honestly, you’re unlikely to get a 10x throughput improvement going from Go to Rust the way you might from Python. What you get is fewer “silly errors” and flatter latency tails, plus the ability to expand into other domains like embedded development or systems programming while still using the same language. That’s often the most surprising side-effect of a migration: there’s a lot of opportunity for code-sharing across teams that previously had to use different stacks. You can use Rust for everything.
Going from Go to Rust is a different kind of migration than coming from Python or TypeScript.
Coming from Go, you know the benefits of a statically-typed, compiled language. So you’re not trading away dynamic typing or a slow runtime, you’re trading away nil in exchange for a more robust codebase with fewer footguns, and a stricter compiler that catches more mistakes at compile time. There is a steeper learning curve, however.
For foundational services (services that your organization relies on, that have high uptime requirements, that are critical to your business), that trade is obviously worth it. For others, Go remains the right answer. The point of a migration is to put each problem in the language that solves it best.
Ready to Make the Move to Rust?
I help backend teams evaluate, plan, and execute Go-to-Rust migrations. Whether you need an architecture review, training, or hands-on help porting a critical service, let’s talk about your needs.
Rust’s type system doesn’t catch all data races, but types that truly can’t be shared between threads without synchronization won’t compile. You can still have logic bugs in your synchronization, but you won’t have the kind of “oh no, I forgot to lock this” that often leads to silent data corruption. ↩
Worth disambiguating, since it trips people up: a Go string is an immutable sequence of bytes that is conventionally (but not guaranteed to be) valid UTF-8. A rune is a Unicode code point (an alias for int32), what you get when you range over a string. []byte is the mutable byte buffer. The closest one-to-one mapping is string (Go) ↔ &str (Rust) for read-only views, and []byte (Go) ↔ Vec<u8> (Rust) for mutable buffers. String in Rust is the owned, growable version of &str, with the additional guarantee that its contents are valid UTF-8 (which Go’s string does not enforce at the type level). For more information, see Strings, bytes, runes and characters in Go. ↩
To be precise, this is about methods that introduce their own type parameters in addition to the receiver’s. Go has had generic functions and generic types since 1.18, so func Map[T, U any](s []T, f func(T) U) []U is fine. What you can’t do is attach that Map to a method on a generic Set[T] and let the caller pick U per call. The Go proposal explicitly punts on this and it has not been added since. ↩
If you’re coming from Go, that line takes a minute to parse: (0..100) is a lazy range, .filter(|n| ...) is a closure (the |n| is the parameter list, no curly braces needed for a single expression), and .collect() materializes the iterator into whatever type the left-hand side asks for. Go is not a particularly functional language, and this iterator-chain style is genuinely an acquired taste, idiomatic Rust leans on it heavily, and the first few weeks it can be a little unfamiliar. You can, of course, still write for loop in Rust, and for one-off code that’s often the right call, but you’ll find that iterator patterns will feel quite natural after a while, and the ability to chain transformations without intermediate variables is a real readability win once you internalize it. (That was at least my experience.) ↩
2026-05-07 08:00:00
Every time you load a website, send an email, or update an app, you’re quietly relying on a handful of unglamorous services that route your packets to the right place: DNS to translate names into addresses, and BGP to figure out how to actually get there. When these systems break, or get attacked, the Internet doesn’t just slow down but stops working.
For more than 25 years, NLnet Labs has been one of the small, non-profit teams keeping that core infrastructure running. Their software, including the DNS servers NSD and Unbound, the RPKI tools Krill and Routinator, and the new DNSSEC signer Cascade, is deployed everywhere from hobbyist Pi-Hole setups to Let’s Encrypt and major Internet operators. And increasingly, it’s written in Rust!
In this episode, I talk to Arya Khanna and Martin Hoffmann from NLnet Labs about what it takes to maintain critical Internet infrastructure as a small team, why they bet on Rust for new projects like the domain crate and Cascade and what the rest of us can learn from a codebase whose users include the people who keep your routes flowing.
CodeCrafters helps you become proficient in Rust by building real-world, production-grade projects. Learn hands-on by creating your own shell, HTTP server, Redis, Kafka, Git, SQLite, or DNS service from scratch.
Start for free today and enjoy 40% off any paid plan by using this link.
NLnet Labs is a non-profit foundation based in Amsterdam that develops open source software and open standards for the core infrastructure of the Internet. Since 1999, the small but dedicated team has built some of the most widely deployed building blocks of the modern web, including the authoritative DNS nameserver NSD, the recursive DNS resolver Unbound, and the RPKI tools Krill and Routinator, which secure global Internet routing. Their work is trusted by operators ranging from hobbyist Pi-Hole users to Let’s Encrypt and major Internet service providers. In recent years, NLnet Labs has been steadily moving its new development to Rust, with projects like the domain crate and the Cascade DNSSEC signer leading the way.
Arc of bytes2026-04-29 08:00:00
In April 2026, Canonical disclosed 44 CVEs in uutils, the Rust reimplementation of GNU coreutils that ships by default since 25.10. Most of them came out of an external audit commissioned ahead of the 26.04 LTS.
I read through the list and thought there’s a lot to learn from it.
What’s notable is that all of these bugs landed in a production Rust codebase, written by people who knew what they were doing, and none of them were caught by the borrow checker, clippy lints, or cargo audit.
I’m not writing this to criticize the uutils team. Quite the contrary; I actually want to thank them for sharing the audit results in such detail so that we can all learn from them.
We also had Jon Seager, VP Engineering for Ubuntu, on our ‘Rust in Production’ podcast recently and a lot of listeners appreciated his honesty about the state of Rust at Canonical.
If you write systems code in Rust, this is the most concentrated look at where Rust’s safety ends that you’ll likely find anywhere right now.
This is the largest cluster of bugs in the audit. It’s also the reason cp, mv, and rm are still GNU in Ubuntu 26.04 LTS. :(
The pattern is always the same. You do one syscall to check something about a path, then another syscall to act on the same path. Between those two calls, an attacker with write access to a parent directory can swap the path component for a symbolic link. The kernel re-resolves the path from scratch on the second call, and the privileged action lands on the attacker’s chosen target.
Rust’s standard library makes this easy to get wrong. The ergonomic APIs you reach for first (fs::metadata, File::create, fs::remove_file, fs::set_permissions) all take a path and re-resolve it every time, rather than taking a file descriptor and operating relative to that.
That’s fine for a normal program, but if you’re writing a privileged tool that needs to be secure against local attackers, you have to be careful.
Here’s the bug, simplified from src/uu/install/src/install.rs.
// 1. Clear the destination
fs::remove_file(to)?;
// ...
// 2. Create the destination. The path is re-resolved here!
let mut dest = File::create(to)?; // follows symlinks, truncates
copy(from, &mut dest)?;
Between step 1 and step 2, anyone with write access to the parent directory can plant to as a symlink to, say, /etc/shadow. Then File::create follows the symlink and the privileged process happily overwrites /etc/shadow with whatever from happened to contain.
The fix uses OpenOptions::create_new(true):
fs::remove_file(to)?;
let mut dest = OpenOptions::new()
.write(true)
.create_new(true)
.open(to)?;
copy(from, &mut dest)?;
The docs for create_new say (emphasis mine):
No file is allowed to exist at the target location, also no (dangling) symlink. In this way, if the call succeeds, the file returned is guaranteed to be new.
A &Path in Rust looks like a value, but remember that to the kernel it’s just a name. That name can point to different things from one syscall to the next.
Anchor your operations on a file descriptor instead.
create_new() only helps with that when you’re creating a new file. For everything else, open the parent directory once and work relative to that handle.
If you act on the same path twice, assume it’s a TOCTOU (Time Of Check To Time Of Use) bug until you’ve proven otherwise.
This is a close relative of TOCTOU. You want a directory with restrictive permissions, so you write something like this.
// Create with default permissions
fs::create_dir(&path)?;
// Fix up permissions
fs::set_permissions(&path, Permissions::from_mode(0o700))?;
For a brief moment, path exists with the default permissions. Any other user on the system can open() it during that window. Once they have a file descriptor, the later chmod doesn’t take it away from them.
Reach for OpenOptions::mode() and DirBuilderExt::mode() so the file or directory is born with the permissions you want. The kernel will apply your umask on top, so set that explicitly too if you really care.
The original --preserve-root check in chmod was literally this:
if recursive && preserve_root && file == Path::new("/") {
return Err(PreserveRoot);
}
That comparison is bypassed by anything that resolves to / but isn’t spelled /. So /../, /./, /usr/.., or a symlink that points to /. Run chmod -R 000 /../ and see it rip right past your check and lock down the whole system.
Here’s the fix:
fn is_root(file: &Path) -> bool {
matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))
}
if recursive && preserve_root && is_root(file) {
return Err(PreserveRoot);
}
canonicalize resolves .., ., and symlinks into a real absolute path. That’s a lot better than string comparison.
Oh and if you were wondering about this line:
matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))
I think that’s just a fancy way of saying
// First, resolve the path to its canonical form
if let Ok(p) = fs::canonicalize(file) {
// If that succeeded, check if the canonical path is "/"
p == Path::new("/")
} else {
false
}
In the specific case of --preserve-root, this works because / has no parent directory, so there’s nothing for an attacker to swap from underneath you. In the more general case of comparing two arbitrary paths for filesystem identity, however, you’d want to open both and compare their (dev, inode) pairs, the way GNU coreutils does. (Think identity, not string equality.)
By the way, my favorite bug in this group is CVE-2026-35363:
rm . # ❌
rm .. # ❌
rm ./ # ✅
rm ./// # ✅
It refused . and .. but happily accepted ./ and .///, then deleted the current directory while printing Invalid input. 😅
Rust’s String and &str are always UTF-8.
That’s a great choice in 99% of all cases, but Unix paths, environment variables, arguments, and the inputs flowing through tools like cut, comm, and tr live in the messy world of bytes.
Every time a Rust program bridges that gap, it has three options.
from_utf8_lossy silently rewrites invalid bytes to U+FFFD. That’s just fancy data corruption.unwrap or ? crashes or refuses to operate.OsStr or &[u8] is what you should usually do.The audit found bugs in both of the first two categories. Here’s an example.
comm (CVE-2026-35346)This is the original code, from src/uu/comm/src/comm.rs.
// ra, rb are &[u8], raw bytes from the input files.
print!("{}", String::from_utf8_lossy(ra));
print!("{delim}{}", String::from_utf8_lossy(rb));
GNU comm works on binary files because it just shuffles bytes around. The uutils version replaced anything that wasn’t valid UTF-8 with U+FFFD, which silently corrupted the output.
Here’s the fix: stay in bytes.
let mut out = BufWriter::new(io::stdout().lock());
out.write_all(ra)?;
out.write_all(delim)?;
out.write_all(rb)?;
print! forces a UTF-8 round-trip through Display. Write::write_all does not.
It writes the raw bytes directly to stdout.
For Unix-flavored systems code, use Path and PathBuf for filesystem paths, OsString for environment variables, and Vec<u8> or &[u8] for stream contents. It’s tempting to round-trip them through String for easier formatting, but that’s where the corruption creeps in.
UTF-8 is a great default for application strings, but it’s absolutely, positively the wrong default for the raw byte stuff Unix tools work with.
panic! as a Denial of ServiceIn a CLI, every unwrap, every expect, every slice index, every unchecked arithmetic operation, every from_utf8 is a potential denial of service if an attacker can shape the input.
That’s because a panic! unwinds the stack and aborts the process. If your tool is running in a cron job, a CI pipeline, or a shell script, that means the whole thing just stops working. Even worse, you could find yourself in a crash loop that paralyzes the entire system.
A canonical case from the audit was sort --files0-from (CVE-2026-35348). The flag reads a NUL-separated list of filenames from a file, but the parser called expect() on a UTF-8 conversion of each name:
// Inside sort.rs, simplified
let path = std::str::from_utf8(bytes)
.expect("Could not parse string from zero terminated input.");
GNU sort treats filenames as raw bytes, the way the kernel does. The uutils version required UTF-8 and aborted the whole process on the first non-UTF-8 path:
$ python3 -c "open('list0','wb').write(b'weird\xffname\0')"
$ coreutils sort --files0-from=list0
thread 'main' panicked at uu_sort-0.2.2/src/sort.rs:1076:18:
Could not parse string from zero terminated input.: Utf8Error { valid_up_to: 5, error_len: Some(1) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
(I reproduced this against coreutils 0.2.2 on macOS. The Python one-liner is there because most modern shells refuse to create a non-UTF-8 filename for you.)
Your nightly cron job is dead and there goes your weekend.
In code that processes untrusted input, treat every unwrap, expect, indexing, or as cast as a CVE waiting to be filed. Use ?, get, checked_*, try_from, and surface a real error. Push back on the boundary of your application and let the caller deal with the fallout.
A good lint baseline to catch this in CI:
[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
indexing_slicing = "warn"
arithmetic_side_effects = "warn"
These are noisy in test code where panicking on bad data is exactly what you want. The cleanest way to scope them to non-test code is to put #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing, clippy::arithmetic_side_effects))] at the top of each crate root, or to gate #[allow(...)] on the individual #[cfg(test)] modules.
Closely related to the previous point, a few CVEs come from ignoring or losing error information.
chmod -R and chown -R returned the exit code of the last file processed instead of the worst one. So chmod -R 600 /etc/secrets/* could fail on half the files and still exit 0. Your script thinks everything is fine.
dd called Result::ok() on its set_len() call to mimic GNU’s behavior on /dev/null. The intent was reasonable, but that same code ran for regular files too, so a full disk silently produced a half-written destination.
The reason was that someone wanted to throw away a Result and reached for .ok(), .unwrap_or_default(), or let _ =.
Here’s a very simple pattern to avoid that:
// Don't bail on the first error, but remember the worst one.
let mut worst = 0;
for file in files {
if let Err(e) = chmod_one(file) {
worst = worst.max(e.exit_code());
}
}
process::exit(worst);
Also, if you write .ok() to discard a Result, leave a comment that explains why this specific failure is safe to ignore.
A surprising number of these CVEs aren’t “the code does something unsafe” but “the code does something different from GNU, and a shell script somewhere relied on the GNU behavior.”
The clearest example is kill -1 (CVE-2026-35369). GNU reads -1 as “signal 1” and asks for a PID. uutils read it as “send the default signal to PID -1”, which on Linux means every process you can see. Yikes!
A typo becomes a system-wide kill switch.
If you reimplement a battle-tested tool, bug-for-bug compatibility on exit codes, error messages, edge cases, and option semantics is a security feature. (Hello, Hyrum’s Law – and obligatory XKCD 1172!)
Anywhere your behavior diverges from the original, somebody’s shell script is making a wrong decision.
uutils now runs the upstream GNU coreutils test suite against itself in CI. That’s the right scale of defense for this class of bug.
CVE-2026-35368 is the worst single bug in the audit. It’s local root code execution in chroot. The bug is visible if you know what to look for (a chroot followed by a function call that loads a dynamic library), but it’s the kind of thing that doesn’t jump out on a first read.
Here’s the pattern, simplified from the chroot utility.
chroot(new_root)?;
// Still uid 0, but now inside the attacker-controlled filesystem.
let user = get_user_by_name(name)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;
Huh. Looks innocent.
The trap is that get_user_by_name ends up loading shared libraries from the new root filesystem to resolve the username. An attacker who can plant a file in the chroot gets to run code as uid 0.
GNU chroot resolves the user before calling chroot. Same fix here.
let user = get_user_by_name(name)?;
chroot(new_root)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;
Once you’re across, every library call might run the attacker’s code. And no, static compilation doesn’t help here, because get_user_by_name goes through NSS, which dlopens libnss_* modules at runtime regardless of whether your binary is statically linked.
You might have made it this far and thought “Wow, that’s a lot of bugs! Maybe Rust isn’t as safe as I thought?”
That would be the wrong conclusion.
Keep in mind that none of the following bad things happened:
That means, even if the tools were (and probably still are) buggy, they never had a bug that could be exploited to read arbitrary memory.
GNU coreutils has shipped CVEs in every single one of those categories. Take a peek at the last few years of the GNU NEWS file:
pwd buffer overflow on deep paths longer than 2 * PATH_MAX (9.11, 2026)numfmt out-of-bounds read on trailing blanks (9.9, 2025)unexpand --tabs heap buffer overflow (9.9, 2025)od --strings -N writes a NUL byte past a heap buffer (9.8, 2025)sort 1-byte read before a heap buffer with a SIZE_MAX key offset (9.8, 2025)ls -Z and ls -l crashes with SELinux but no xattr support (9.7, 2025)split --line-bytes heap overwrite (CVE-2024-0684, 9.5, 2024)b2sum --check reads unallocated memory on malformed input (9.4, 2023)tail -f stack buffer overrun with many files and a high ulimit -n (9.0, 2021)…the list goes on and on. The Rust rewrite has shipped zero of these, over a comparable window of activity.1 That’s most of what historically goes wrong in a C codebase.
What’s left is, frankly, a more interesting class of bug. It lives at the boundary between our controlled Rust environment and the messy, chaotic outside world, where paths, bytes, strings, and syscalls are all tangled up in one eternal ball of sadness. That’s the new security boundary of modern systems code.2
If you write systems code in Rust, treat this CVE list as a checklist. Grep your own codebase for from_utf8_lossy, stray unwrap() calls, discarded Results, File::create, and string comparisons against "/".
I also wrote a companion post, titled Patterns for Defensive Programming in Rust.
When I think of “idiomatic Rust”, correctness is not the first thing that comes to mind. After all, isn’t that the compiler’s job? Instead, I think of elegant iterator patterns, ergonomic method signatures, immutability, or clever use of expressions. But none of that matters if the code doesn’t do the right thing, and the compiler is far from perfect at enforcing correctness. That’s why we don’t only have idioms for writing more elegant code; we also have idioms for writing correct code. They are the distilled experience of a community that has learned, often painfully, which shapes of code survive contact with reality and which ones do not.
Reality is rarely as tidy as the abstractions we would like to impose on it. The mark of robust systems, in any language, is the willingness to reflect that untidiness rather than paper over it. Rust gives us extraordinary tools to do so, and the compiler will hold a great deal for us. But the part it cannot hold, the boundary between our program and everything else, is still ours to get right. The type system can encode many things, but it cannot encode conditions outside of its control, such as the passage of time between two syscalls.
Idiomatic Rust, then, is not just code that the borrow checker accepts or that clippy leaves alone. It is code whose types, names, and control flow tell the truth about the system they run in. And that truth is sometimes ugly. It could mean using file descriptors instead of paths, OsStr instead of String, ? instead of unwrap, and bug-for-bug compatibility over clean semantics. None of it is as pretty as the version you would write on a whiteboard. But it is more honest.
Need Help Hardening Your Rust Codebase?
Is your team shipping Rust into production and want to make sure you’re not falling into the same traps? I offer Rust consulting services, from code reviews and security-focused audits to training your team on the patterns that the compiler won’t enforce for you. Get in touch to learn more.
To be fair to GNU: GNU coreutils is 40 years old and has had a very long time to surface and fix this class of bug. And we don’t know there are no memory-safety bugs in the Rust rewrite, only that the audit didn’t find any. Still, the difference is noticeable when comparing the same duration of development activity. ↩
It’s worth noting that the Path/PathBuf TOCTOU class of bug is in some ways easier to avoid in C than in Rust. C code naturally reaches for an open file descriptor and the *at family of syscalls (openat, fstatat, unlinkat, mkdirat), and most creation syscalls take a mode argument directly. Rust’s high-level std::fs APIs abstract over the file descriptor and operate on &Path values, which makes the path-based, re-resolving call the path of least resistance. The handle-based APIs exist on every Unix platform; Rust just doesn’t put them front and center. ↩
2026-04-23 08:00:00
Jon Gjengset is one of the most recognizable names in the Rust community, the author of Rust for Rustaceans, a prolific live-streamer, and a long-time contributor to the Rust ecosystem. Today he works as a Principal Engineer at Helsing, a European defense company that has made Rust a foundational part of its engineering stack. Helsing builds safety-critical software for real-world defense applications, where correctness, performance, and reliability are non-negotiable. In this episode, Jon talks about what it means to build mission-critical systems in Rust, why Helsing bet on Rust from the start, and what lessons from his years of Rust education have shaped the way he writes and thinks about production code.
CodeCrafters helps you become proficient in Rust by building real-world, production-grade projects. Learn hands-on by creating your own shell, HTTP server, Redis, Kafka, Git, SQLite, or DNS service from scratch.
Start for free today and enjoy 40% off any paid plan by using this link.
Founded in 2021, Helsing is a European defence company building AI-enabled software for some of the most demanding environments imaginable. Helsing’s software runs where correctness is non-negotiable. That philosophy led them to Rust early on and they’ve leaned into it fully. From coordinate transforms to CRDT document stores to Protobuf package management, almost everything they build ends up being written in Rust.
Jon holds a PhD from MIT’s PDOS group, where he built Noria, a high-performance streaming dataflow database, and later co-founded ReadySet to continue that work commercially. He then spent time building infrastructure at AWS, before joining Helsing as a Principal Engineer. Outside of his day job, he’s been teaching Rust to the world through his livestreams and writing for years, which makes him a rare combination: someone who thinks deeply about both how to use Rust and how to explain it.
anyhow with support for customizable, pluggable error report handlersapache-avro cratetar crate that affected Cargo’s package extraction2026-04-09 08:00:00
Rust adoption can be loud, like when companies such as Microsoft, Meta, and Google announce their use of Rust in high-profile projects. But there are countless smaller teams quietly using Rust to solve real-world problems, sometimes even without noticing. This episode tells one such story. Cian and his team at Cloudsmith have been adopting Rust in their Python monolith not because they wanted to rewrite everything in Rust, but because Rust extensions were simply best-in-class for the specific performance problems they were trying to solve in their Django application. As they had these initial successes, they gained more confidence in Rust and started using it in more and more areas of their codebase.
CodeCrafters helps you become proficient in Rust by building real-world, production-grade projects. Learn hands-on by creating your own shell, HTTP server, Redis, Kafka, Git, SQLite, or DNS service from scratch.
Start for free today and enjoy 40% off any paid plan by using this link.
Made with love in Belfast and trusted around the world. Cloudsmith is the fully-managed solution for controlling, securing, and distributing software artifacts. They analyze every package, container, and ML model in an organization’s supply chain, allow blocking bad packages before they reach developers, and build an ironclad chain of custody.
Cian is a Service Reliability Engineer located in Dublin, Ireland. He has been working with Rust for 10 years and has a history of helping companies build reliable and efficient software. He has a BA in Computer Programming from Dublin City University.