MoreRSS

site iconCorrcodeModify

This is an ongoing series of articles about idiomatic Rust and best practices.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Corrcode

JetBrains

2026-07-30 08:00:00

Welcome to the final episode of this season of Rust in Production. My guest is Orhun Parmaksız from JetBrains, and we talk about building developer tools with Rust.

JetBrains is best known for IntelliJ IDEA, Kotlin, and a long line of IDEs for professional software teams. In the Rust world, that now includes RustRover: a commercial IDE built on the IntelliJ platform, with deep Rust support for navigation, refactoring, debugging, testing, and large codebases.

This episode is about where Rust fits into that world. We talk about why JetBrains does not plan to rewrite the IntelliJ platform in Rust, why Fleet used Rust for its File System Daemon, how Air builds on parts of Fleet’s architecture, and why JetBrains prefers out-of-process Rust helpers over JNI inside the JVM. We also get into RustRover’s internals: PSI, THIR, MIR-based expression evaluation in the debugger, procedural macro sandboxing, library stubs, parser regression testing, cargo-nextest support, and the practical trade-offs between JetBrains’ indexing model and rust-analyzer’s Salsa-based approach.

Proudly Supported by CodeCrafters

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.

Show Notes

Links From The Episode

Official Links

Understanding Dyn Compatibility

2026-07-29 08:00:00

In Rust, some traits can’t be used as trait objects with dyn Trait.

When a trait can’t be used with dynamic dispatch, we say it’s “not dyn compatible.” 1 This has an impact on how you can use these traits in your code.

I think that’s one area where the Rust compiler could print a more helpful error message.

Fixing the issue is mostly about tradeoffs between compile-time generics and runtime polymorphism and learning when each one fits. Once you understand the concept, you’ll know how to get around the issues by choosing a better design for your trait.

Quick Help

If the compiler told you a trait is “not dyn compatible”, your trait can’t be used as dyn Trait because it has a method that can’t go through dynamic dispatch, usually one that returns Self, takes no self, or is generic.

To fix it, pick one:

  • add where Self: Sized to the offending method
  • return Box<dyn Trait> instead of Self
  • use generics instead of &dyn Trait
  • split the trait in two

Continue reading to understand the tradeoffs between each approach.

The Error Message

Here’s an example with code that won’t compile.

Say you have a trait Widget that has a method returning a copy of itself:

trait Widget {
    fn draw(&self);
    fn duplicate(&self) -> Self;  // Returns a copy of itself
}

…and there’s a button, which implements Widget:

struct Button {
    label: String,
}

impl Widget for Button {
    fn draw(&self) {
        // ...
    }

    fn duplicate(&self) -> Self {
        Button { label: self.label.clone() }
    }
}

fn show_widget(widget: &dyn Widget) {
    // This works
    widget.draw();

    // This produces an error because duplicate returns `Self`
    let copy = widget.duplicate();
    copy.draw();
}

If you tried to compile this code, you’d get an error like this:

error[E0038]: the trait `Widget` is not dyn compatible
  --> src/main.rs:20:25
   |
20 | fn show_widget(widget: &dyn Widget) {
   |                         ^^^^^^^^^^ `Widget` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/main.rs:3:28
   |
 1 | trait Widget {
   |       ------ this trait is not dyn compatible...
 2 |     fn draw(&self);
 3 |     fn duplicate(&self) -> Self;  // Returns a copy of itself
   |                            ^^^^ ...because method `duplicate` references the `Self` type in its return type
   = help: consider moving `duplicate` to another trait
   = help: only type `Button` implements `Widget`; consider using it directly instead.

That all sounds pretty confusing.

  • What does “not dyn compatible” mean?
  • Shouldn’t the dyn part take care of it?
  • What’s a “vtable”, and why does the trait need to “allow building” one?
  • What does it have to do with Self?

What’s going on?

When you use &dyn Trait, Rust creates a trait object. Trait objects use dynamic dispatch to call methods at runtime. Dynamic dispatch just means that the exact method to call is determined at runtime based on the actual type of the object.

For dynamic dispatch to work, the trait’s dispatchable API must follow certain rules.

  1. Dispatchable methods must not return Self.
  2. Dispatchable methods must have an allowed receiver (&self, &mut self, Box<Self>, and a few related pointer forms). Plain static methods don’t have one.
  3. Dispatchable methods must not have generic type parameters.

These are simplifications: each method-level rule is really “…unless that method opts out with where Self: Sized”, which we’ll see in a moment. Traits also have a few item-level restrictions, such as no associated constants; we’ll summarize the fuller list later. For now, the rough version is enough to build intuition.

In our example, we violate the first rule: the duplicate method returns Self, which means “the same type as the implementor of the trait”. When you use &dyn Widget, the concrete implementor is hidden behind the trait-object interface. The vtable still points to the right concrete implementation, but the call site has no single concrete return type it can name for duplicate. That’s a problem, because the compiler needs to know the size of the return value at compile time, and Self could be any size. It needs to know the size, because the returned value has to live somewhere: the caller sets aside exactly the right amount of space (usually on the stack) before the call even happens. With a &dyn Widget, the concrete type is erased from the caller’s static type, so there’s no single size the compiler could reserve for it.

It will become clearer once we look at some fixes.

How To Fix It

Don’t worry, we won’t have to refactor all our code! All fixes use the same Widget trait example. There are multiple ways to make it dyn compatible.

We have a bunch of options:

  1. Use generics instead
  2. Opt out problematic methods with where Self: Sized
  3. Return boxed trait objects instead of Self
  4. Split into two traits

Each approach comes with different tradeoffs. Depending on the kind of dyn-compatibility issue, one might fit better than the others, or you might combine a few. Let’s look at each of these in detail.

Fix #1: Use Generics Instead

One common way to fix the problem is to use generics instead of trait objects. Generics resolve to concrete types at compile time, so the compiler knows the size of Self. The compiler generates a separate copy of the function for each concrete type that implements the trait. Then, at runtime, you no longer need to worry about any dynamic dispatch (which means “figuring out the type at runtime”). The compiler always knows which type it is dealing with, so it can pick the right method to call.

Our trait stays the same:

trait Widget {
    fn draw(&self);
    fn duplicate(&self) -> Self;
}

But now we change the function which uses the trait to use generics instead of dyn:

// Instead of: fn show_widget(widget: &dyn Widget)
// use generics:
fn show_widget<W: Widget>(widget: &W) {
    widget.draw();
    let copy = widget.duplicate();
    copy.draw();
}

Note how we changed the function signature to use a generic type parameter W that implements the Widget trait. Here we tell Rust: “I have some type W that implements Widget, and I want to use it.” Rust then generates the necessary code for each type used.

That’s close to using &dyn Widget, but not quite the same. The difference is that with generics, the compiler knows the concrete type at compile time, so it can handle Self correctly. For instance, we might know that W is Button in this case, so duplicate returns a Button. Now the confusion about what Self means is gone!

The downside is that you can’t fully lean on dynamic dispatch anymore, and you might have to refactor a lot of code if you were using trait objects extensively before. Your binary size might also grow because of all the copies of the function that the compiler generates for each concrete type.

What’s the benefit of fully leaning on dynamic dispatch?

Fair question! Dynamic dispatch has a bunch of really nice properties:

  • It’s very flexible. You can swap out implementations at runtime, which is great for plugins or when you want to change behavior without recompiling.
  • It allows for polymorphism. You can treat different types that implement the same trait uniformly, which can simplify code that needs to work with various types. You could technically do the same with generics, but sometimes you can’t afford the increase in code size or compile times that come with monomorphization.
  • It can lead to cleaner and more maintainable code in certain scenarios, especially when dealing with complex hierarchies of types and behaviors. For example, take a graphics rendering engine where you have different shapes (circles, squares, triangles) that all implement a Drawable trait. Using dynamic dispatch, you can store them all in a single collection and call draw(). If you were to try the same with generics, you’d end up with a lot of boilerplate code to handle each shape type separately.

Fix #2: Opt Out Problematic Methods with where Self: Sized

Another option is to keep using trait objects but change the problematic method to only work with concrete types.

trait Widget {
    fn draw(&self);

    // Only available when the concrete type is known
    fn duplicate(&self) -> Self
    where
        Self: Sized;
}

This means “this method can only be called when Self has a known size at compile time”, which is true for concrete types but not for trait objects. It’s more explicit, since you control how the trait can be used. The catch is that it limits the trait further down the line: some methods won’t be callable on every trait object, and changing the trait later becomes a breaking change.

You won’t be able to call duplicate on &dyn Widget, but you can still call it on concrete types like Button.

fn main() {
    let button = Button { label: "Click me".to_string() };

    // Can use as trait object now!
    let widget: &dyn Widget = &button;
    widget.draw();  // ✅ Works

    // ❌ Can't call this on trait objects
    // widget.duplicate();

    // ✅ But duplicate still works on concrete types:
    let button2 = button.duplicate();
}

So you keep most of the flexibility of trait objects (unlike with generics), as long as you remember that some methods won’t be available through dyn Trait.

Fix #3: Return Boxed Trait Objects Instead of Self

We can change the return type of the problematic method to return a boxed trait object instead of Self.

This works because Box<dyn Widget> has a known size at compile time. It’s a pointer to an object on the heap. It’s actually a fat pointer: two words wide, or 16 bytes on a 64-bit system, because it also stores a pointer to the vtable; more on that later. What matters is that this size is fixed and known at compile time, unlike Self, which varies based on the concrete type.

trait Widget {
    fn draw(&self);
    fn duplicate(&self) -> Box<dyn Widget>;  // Returns trait object instead of Self
}
struct Button {
    label: String,
}

impl Widget for Button {
    fn draw(&self) {
        println!("Button: {}", self.label);
    }

    fn duplicate(&self) -> Box<dyn Widget> {
        Box::new(Button { label: self.label.clone() })
    }
}

fn main() {
    // Now we can use trait objects!
    let widgets: Vec<Box<dyn Widget>> = vec![
        Box::new(Button { label: "Click me".to_string() }),
        Box::new(Button { label: "Submit".to_string() }),
    ];

    for widget in &widgets {
        widget.draw();
        let copy = widget.duplicate();
        copy.draw();
    }
}

The downside is that Box<dyn> tends to be viral in your codebase. You’ll end up writing Box<dyn Widget> more often than you’d like, which gets noisy.

On top of that, this fix only works for methods that return Self. If your trait also has static methods or generic methods, you’ll need to combine this approach with one of the other fixes.

Fix #4: Split Into Two Traits

Sometimes the best solution is to separate the dyn-compatible methods from the problematic ones into different traits.

Maybe your code is silently trying to tell you that you are mixing up two different responsibilities and that they should be untangled.

In general, prefer smaller, focused traits over large, monolithic ones. Traits are not interfaces! Instead, we lean on composition and focus on behavior rather than mangling multiple ideas into a single trait.

Here’s a more realistic example: separating rendering from widget creation. Factory methods are often static (no self parameter), which makes them incompatible with dyn. So we split them off into a separate trait.

// This trait can be used with dyn
trait Widget {
    fn draw(&self);
}

// Separate trait for creating widgets. Can't be used with `dyn`
trait WidgetFactory {
    fn create(label: String) -> Self;  // No self parameter!
}

struct Button {
    label: String,
}

impl Widget for Button {
    fn draw(&self) {
        println!("Button: {}", self.label);
    }
}

impl WidgetFactory for Button {
    fn create(label: String) -> Self {
        Button { label }
    }
}

fn main() {
    // Use the factory to create widgets
    let button = Button::create("Click me".to_string());

    // Use as trait object for drawing
    let widget: &dyn Widget = &button;
    widget.draw();  // ✅ Works

    // Can't do this: let factory: &dyn WidgetFactory = ...
    // But that's fine - factories work at compile time
}

What’s Going On Under the Hood?

When you write &dyn Trait, you’re creating a trait object. It’s a special kind of value that consists of two pointers (a “fat pointer”):

┌─────────────────┐
│  Data Pointer   │ --> points to actual data (String, i32, etc.)
├─────────────────┤
│ VTable Pointer  │ --> points to virtual method table
└─────────────────┘

As you can see, a trait object has:

  1. A data pointer that points to the actual data (the concrete type implementing the trait)
  2. A vtable pointer that points to a table of function pointers for the methods

The vtable is created at compile time and contains pointers to the methods for the specific type. It is common in many programming languages that support dynamic dispatch, such as C++, C#, or D. When you call a method on a trait object, Rust uses the vtable to look up the correct function to call based on the actual type of the data.

For dynamic dispatch to be sound, the vtable-facing methods need stable, concrete function signatures:

  • every dispatchable method needs a receiver that leads to the object and its vtable
  • argument and return types must be expressible without knowing the hidden concrete Self
  • the vtable must contain a finite set of function pointers, known at compile time

If a trait has dispatchable methods that return Self or have generic parameters, there is no single vtable entry with one concrete signature that can represent all possible calls.

That is the root cause of dyn compatibility issues.

A trait is dyn compatible if it follows a list of rules.

Click here for the full list.
Rule Why?
All supertraits must also be dyn compatible A dyn Subtrait also exposes the supertrait API, so those inherited methods must be dispatchable too
No Self: Sized supertrait The trait object type dyn Trait is unsized, so the trait itself must not require Self: Sized
No associated constants Associated constants are not entries in the method vtable
No generic associated types The Reference currently forbids associated types with generics on dyn-compatible traits
Dispatchable methods must have an allowed receiver Methods need a receiver: &self, &mut self, or pointer receivers like Box<Self>, Rc<Self>, Arc<Self>, or Pin<P> where P is one of those pointer forms. Static methods (no receiver) can’t be dispatched through a trait object
No generic type parameters on dispatchable methods The vtable is a finite structure created at compile time. Generic methods are monomorphized at compile time (one copy per concrete instantiation), but a trait object erases the concrete receiver type
No Self in dispatchable method parameters except the receiver other: &Self means “the same concrete type as self”, but with trait objects we only know both are dyn Comparable; they could hide different underlying types
No Self return type on dispatchable methods The caller needs to know the return value’s size and type, but Self could be any implementor
No opaque return type on dispatchable methods async fn and return-position impl Trait hide a concrete return type that must be known statically
Non-dispatchable methods must opt out A method that violates the dispatch rules can still live on the trait if it has where Self: Sized, making it unavailable through dyn Trait

The rules boil down to the same core issue: the dyn Trait interface must have a finite, statically-known shape even though the concrete implementor behind it is hidden.

A Modern Gotcha: async fn in Traits

Since Rust 1.75, you can write async fn directly in a trait. But there’s a catch: a trait with an async fn is not dyn compatible.

An async fn desugars to a regular method that returns impl Future<...>, a hidden return-position impl Trait. The type is called “opaque”, because we don’t know what it is, and the compiler doesn’t expose it to us. Opaque return types aren’t dispatchable (which means we can’t put them in a vtable of functions), so the trait can’t be used behind dyn.

If you need dynamic dispatch with async methods today, you have a few options:

  • Box the future yourself and return Pin<Box<dyn Future<Output = ...>>>.
  • Use the async-trait crate, which does that boxing for you.
  • Use the dynosaur crate, which generates a dyn-compatible wrapper for traits with async fn.

Summary

Dyn compatibility determines if a trait can be used with dyn Trait. The rules exist because:

  1. Trait objects use dynamic dispatch via vtables
  2. Vtables are static, compile-time structures, which hold method pointers
  3. Type information is erased at runtime to allow polymorphism
  4. The compiler must guarantee type safety at all times, even if it can’t see the concrete type

If your trait is not dyn compatible, don’t worry! Many standard library traits (Clone, Default, etc.) are also not dyn compatible. As we’ve seen, there are ways to work around these limitations with type erasure, generics, or more fine-grained traits.

Which fix to reach for depends on what your trait needs and what you’re willing to give up:

Fix Reach for it when… The tradeoff
#1 Generics (<W: Widget>) You don’t actually need trait objects (the concrete type is known at each call site) and you won’t mix different types in one collection Static dispatch only; monomorphization can grow code size and compile times and generic parameters need to be passed around in your API
#2 where Self: Sized You want to keep using dyn Widget, and the problematic method only ever needs to be called on concrete types That method isn’t callable through dyn; tightening the bound later is a breaking change
#3 Return Box<dyn Widget> The method returns Self and you really need it through a trait object (e.g. a heterogeneous Vec<Box<dyn Widget>>) A heap allocation per call, and Box<dyn> tends to spread through your API
#4 Split into two traits The trait mixes dispatchable behavior with non-dispatchable bits, like static factory methods More traits to keep track of, though that separation often helps
async-trait / dynosaur Your trait has async fns and you need to call them through dyn Wrapper types and usually boxed futures/extra indirection until native dyn async improves

In practice you’ll often combine these. For example, splitting a trait and boxing a return value.

Historical Notes

I find it interesting to see how dyn compatibility evolved over time in Rust. If you do, too, here are some resources to dig deeper:

  • 2014-09-22: RFC 255 - Introduced object safety (2014, before Rust 1.0)
  • 2014-11-03: Issue #428 - Object-safety and static methods
  • 2015-01-03: RFC 546 - Removed implied Sized bound on traits
  • 2023-08-24: Rust 1.72 - GATs can be opted out with where Self: Sized
  • 2023-12-28: Rust 1.75.0 - Stabilized async fn and return-position impl Trait in traits (though such traits still aren’t dyn compatible)
  • 2025-01-09: Rust 1.84.0 - The docs had moved from “object safety” to “dyn compatibility” around this release cycle; the tracking issue notes that the rename unfortunately missed the release notes.

The lang team also wants a “practical path” to call async fns through dyn Trait natively. It’s on the 2026 project goals, so the async gotcha above should ease over time.

  1. The concept used to be called “object safety” until Rust 1.84.0. If you’re reading older resources, they mean the same thing. The name got changed because it was confusing.

    “Object safety” suggests that Rust has “objects” in the traditional OOP sense and that the term is about “safety”, which is misleading. The new term “dyn compatibility” does a better job of saying that it’s about whether a trait can be used with dyn Trait for dynamic dispatch. I still don’t love either term, but I also can’t think of a better name that is both short and accurate.

Hardening Rust Code For Production

2026-07-23 08:00:00

We talked about patterns for defensive programming in Rust before, in which implicit invariants that aren’t enforced by the compiler lead to utter misery. But being careful isn’t enough! Even valid code can fail at runtime in ways that are hard to predict and control. That’s what we’re covering next.

This article is for you if you want to…

  • make your code resilient at runtime
  • harden your Rust code for production
  • know how Rust code can fail in unexpected ways and how to recover from that

Table of Contents

Click here to expand the table of contents.

Panic Semantics Are Part of Your API

What happens when a Rust program panics? There is no single correct answer because panic! is not a “single behavior.”

Unwind vs. Abort

For starters, there’s a difference between unwind and abort.

catch_unwind invokes a closure, which captures the cause of an unwinding panic.

let result = panic::catch_unwind(|| {
    panic!("oh no!");
});

But the Rustonomicon has the following to say about unwinding panics:

We would encourage you to only do this sparingly. In particular, Rust’s current unwinding implementation is heavily optimized for the “doesn’t unwind” case. If a program doesn’t unwind, there should be no runtime cost for the program being ready to unwind.

The alternative to unwinding is aborting the entire process. That does what it says on the tin: the program immediately terminates without unwinding the stack or running destructors. Halt and catch fire. Weirdly enough, that’s often the safer choice, especially when dealing with FFI boundaries or performance-critical code. That’s because unwinding across FFI boundaries is undefined behavior, and unwinding can be expensive in performance-sensitive code.

To enable aborting on panic, add the following to your Cargo.toml:

[profile.release]
panic = "abort"

And even if you did not explicitly configure this, catastrophic panics like stack overflows and out-of-memory errors always abort the process. That’s because unwinding in these situations is unsafe and can lead to undefined behavior.

In practice, this shows up in two places:

These failures are fundamentally different from ordinary panics in that they cannot be caught or recovered from. To handle them gracefully, you need to know exactly how and where your program will run, and design accordingly. For example, in the case of malloc, avoid unbounded user input that could lead to excessive allocations.

Thread-Level vs. Process-Level Failures

Another difference is between thread-level failures and process-level crashes.

A common misunderstanding is that panic terminates the entire program, but in a multi-threaded application, that is not necessarily the case. For example, a background worker thread can panic while the main thread continues running. What sounds like a benefit can leave the system in a partially degraded state.

This distinction becomes especially important in long-running systems (servers, workers, async runtimes, …). A panic in a request-handling thread might only abort that one request, while the rest of the service remains available. Here’s a small example using scoped threads (Playground):

use std::{thread, time::Duration};

fn handle_request(id: u32) {
    println!("request {id}: started");

    if id == 2 {
        panic!("request {id}: handler panicked");
    }

    thread::sleep(Duration::from_millis(100));
    println!("request {id}: finished");
}

fn main() {
    thread::scope(|s| {
        let requests: Vec<_> = (1..=3)
            .map(|id| (id, s.spawn(move || handle_request(id))))
            .collect();

        for (id, request) in requests {
            match request.join() {
                Ok(()) => println!("main: request {id} completed"),
                Err(_) => println!("main: request {id} failed, but the process is still alive"),
            }
        }
    });

    println!("main: service keeps running");
}

The interesting part of the output is this:

request 1: finished
main: request 1 completed
main: request 2 failed, but the process is still alive
request 3: finished
main: request 3 completed
main: service keeps running

Request 2 panics, but requests 1 and 3 still finish. The panic belongs to the worker thread. The main thread gets notified on join() but keeps running. 1

Whether this is acceptable depends on the system’s invariants. If a panic indicates a violated assumption confined to a small scope, like a single request, letting the process continue may be reasonable. But if it signals a global invariant violation, continuing execution can be outright dangerous.

Panic behavior is part of your system’s failure model. Treating all panics as equivalent hides important distinctions and leads to fragile assumptions. Be explicit about whether a failure may take down a single task, a single thread, or the entire process.

Never panic in an uncontrolled manner.

If you maintain a library, you have less control over where your code runs and what a panic can take down. Consider enabling stricter Clippy lints such as indexing_slicing and arithmetic_side_effects to catch common panic sources before they become part of your public API. Those lints can be noisy in applications, but they are often useful when panic freedom matters more than convenience.

Observing Failures With Panic Hooks

Now that you understand how panics work, let’s talk about operational hardening.

When things go wrong, you want to know about it. But by default, Rust panics just print to stderr and disappear into the void. In production systems, that’s not so great.

You might prefer crash reporting or centralized failure handling, and that’s where panic hooks come in. A panic hook is a function that gets called whenever a panic occurs, giving you a chance to record the failure before the program terminates or unwinds. It will not make an invalid state safe again. Its job is to capture enough context to debug the failure, alert someone, and shut down cleanly when possible.

Example Panic Hooks

Here’s a simple example of setting a panic hook:

use std::panic;

fn main() {
    panic::set_hook(Box::new(|panic_info| {
        eprintln!("Panic occurred: {panic_info}");
        // Log to your monitoring system
        // Send crash reports
        // Clean up resources
    }));

    panic!("Something went wrong!");
}

And here’s a panic hook that sends structured JSON data to a crash reporting service:

panic::set_hook(Box::new(|panic_info| {
    let panic_data = serde_json::json!({
        "message": panic_info.to_string(),
        "location": panic_info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())),
        "timestamp": chrono::Utc::now().to_rfc3339(),
        "version": env!("CARGO_PKG_VERSION"),
    });

    // Send to your crash reporting service
    crash_reporter::report(panic_data);
}));

What’s Inside PanicInfo?

The PanicInfo struct contains the panic message (via .payload()) and the source location where the panic occurred (via .location()). Be aware that both can leak sensitive information: file paths may reveal internal directory structure, and panic messages might contain interpolated user data.

And finally, here’s Sentry’s panic hook handler, which is even more sophisticated:

fn setup(&self, _cfg: &mut ClientOptions) {
    INIT.call_once(|| {
        let next = panic::take_hook();
        panic::set_hook(Box::new(move |info| {
            panic_handler(info);
            next(info);
        }));
    });
}

Sentry’s panic hook:

  • Logs the panic information
  • Preserves the previous panic hook behavior by calling next(info)
  • Ensures the hook is only set once using INIT.call_once

There’s a lot to learn from these few lines of code!

Sanitizing Sensitive Data

Panic hooks are also your final opportunity to prevent information leaks. The sensitive data can come from two places: the panic payload and the panic location. The payload is whatever your code passed to panic!, unwrap, expect, or an assertion. That means it can contain interpolated user input, internal state from Debug output, request headers, tokens, email addresses, IP addresses, customer IDs, or other identifiers. The location can expose source file paths, workspace names, or CI/build machine directory layouts.

A well-designed panic hook sanitizes these messages before they reach logs or crash reports. Better yet, avoid putting secrets or raw user data into panic messages in the first place. Prefer stable error codes, request IDs, or redacted domain types. Regexes can catch obvious patterns like email addresses and bearer tokens. UUIDs and IP addresses can also identify users. Treat those checks as your final fallback.

panic::set_hook(Box::new(|panic_info| {
    let sanitized_message = sanitize_panic_message(panic_info.to_string());
    log::error!("Application panic: {sanitized_message}");
}));

You can look into crates like expunge or veil to automatically redact sensitive information from structs:

use veil::Redact;

#[derive(Redact)]
pub struct Customer {
    id: u64,

    #[redact(partial)]
    first_name: String,

    #[redact(partial)]
    last_name: String,

    #[redact]
    email: Option<String>,

    #[redact(fixed = 2)]
    age: u32,

    #[redact(with = "[REDACTED]")]
    address: String,
}

Cleanup Operations

Before the process terminates, you might want to flush logs, close network connections, or notify other systems that this instance is going down. Setting a hook is a great way to perform such cleanup operations.

Panic Hooks Run in a Compromised Environment

Be careful: one of the subsystems you want to interact with might be the cause of the panic you’re handling! For example, if your database connection pool panicked, trying to flush pending writes to that same pool will likely fail or hang. Keep cleanup operations fault-tolerant and avoid anything that can panic, block indefinitely, or depend on the subsystem that just failed.

Limitations

Panic hooks only run for unwinding panics. If your program aborts on panic, or if the panic is caused by a stack overflow or out-of-memory condition, your hook won’t execute.

Never rely on panic hooks for correctness.

They’re purely for observability and graceful degradation; don’t try to recover from logic errors as it is very hard to rely on a system’s fragile underpinnings at this stage.

Stack Overflows And Runtime Behavior

Okay, you handle errors gracefully and you know how your system behaves on panic. Panic behavior isn’t the only runtime failure mode you need to worry about.

Here’s some simple recursive code. What is wrong with it?

fn factorial(n: u64) -> u64 {
    if n == 0 {
        1
    } else {
        n * factorial(n - 1)
    }
}

The problem is that recursion can quickly exhaust stack space.

If you allow users to call this function with large inputs, it might crash your program. Rust does not guarantee tail-call optimization on stable Rust. Some compilers and languages can turn certain tail-recursive functions into loops, but you should not rely on that transformation in Rust. If recursion depth depends on user input or external data, rewrite the algorithm iteratively or put an explicit bound on the depth.

It takes some experience, but for recursive algorithms where you’re not in control of the input size, it’s often safer to use an iterative approach:

fn factorial(n: u64) -> u64 {
    let mut result = 1;
    for i in 1..=n {
        result *= i;
    }
    result
}

Release and Debug Builds Are Two Different Programs

One of the most dangerous assumptions in Rust development is that debug and release builds are functionally equivalent. They’re not. In many ways, you’re shipping a different program than the one you tested.

The most obvious difference is integer overflow behavior. Debug builds panic on overflow, while release builds silently wrap around. We covered that in Pitfalls of Safe Rust.

But the differences run deeper than arithmetic. Release builds remove debug_assert! checks, enable optimizations, and may exercise different code paths behind cfg(debug_assertions). Unsafe code and FFI boundaries are especially sensitive to this: undefined behavior can appear harmless in debug mode and break only once the optimizer starts relying on Rust’s aliasing and validity rules.

Here is a trivial example:

fn apply_discount(price: u32, percent: u32) -> u32 {
    debug_assert!(percent <= 100);
    price - (price * percent / 100)
}

In a debug build, apply_discount(100, 150) trips the debug_assert!. In a release build, the assertion is gone. The subtraction can underflow and wrap around, turning an invalid discount into a huge number. If the check protects a real runtime invariant, use assert! or return a Result instead of relying on debug_assert!.

Testing Release Behavior

The fact that tests pass in debug mode does not prove that production behavior is correct. Run normal debug tests as the fast default, and add release-mode tests for critical integration tests, arithmetic-heavy code, unsafe or FFI-heavy code, and anything whose behavior depends on optimization or release-only configuration.

# Add this to your CI pipeline alongside regular `cargo test`
cargo test --release

Supply-Chain Security

Your code is only as safe as your dependencies. You should regularly audit your dependencies for known vulnerabilities. Two helpful tools for that are cargo-audit and cargo-deny. It’s recommended to run those as part of CI.

cargo-audit run

Secure Allocations With mimalloc

mimalloc is a drop-in global allocator built by Microsoft. What’s special about it is that it also has a secure mode, which adds mitigations like guard pages, randomized allocation, and encrypted free lists to make some heap-corruption bugs harder to exploit. 2

Safe Rust already prevents most use-after-free and buffer-overflow bugs, and a secure allocator does not magically make memory-unsafe code safe. This is mostly defense-in-depth for programs with unsafe code, custom allocators, C/C++ dependencies, or FFI-heavy boundaries.

To enable secure mode, put this in Cargo.toml:

[dependencies]
mimalloc = { version = "0.1", features = ["secure"] }

Then use it as your global allocator:

use mimalloc::MiMalloc;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

Now, all heap allocations in your Rust program will use mimalloc’s secure allocator. Measure the performance impact on your workload before rolling this out broadly; allocator choice can matter a lot for latency-sensitive services, games, packet processing, and other allocation-heavy programs.

Limit Your Runtime Attack Surface

Even well-written Rust code can be compromised through its dependencies, environment, or C FFI boundaries. The idea is to reduce your blast radius. Now, how you do that depends on your deployment environment, but generally people use Docker and Linux, so I thought I’d share some techniques for those; specifically, how to build minimal container images and filesystem sandboxing.

Minimal Docker images

A minimal production image contains exactly what you put in it. Even if your service is compromised, the attacker has very limited tools at their disposal to do further damage.

My recommendation is Google’s distroless images, but please do your own research3 as I’m not an expert on this.

Distroless images are minimal Debian-based images stripped of everything unnecessary, while still including TLS certificates and a non-root user. For a typical Rust web service, start with gcr.io/distroless/cc-debian13:nonroot: it includes the C runtime libraries that a normal Debian-built Rust binary may dynamically link against, but no shell or package manager. (Check the latest version in the distroless README.)

Here is an example Dockerfile using cargo-chef for dependency caching:

# syntax=docker/dockerfile:1

ARG RUST_VERSION=1.92

FROM rust:${RUST_VERSION}-bookworm AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

COPY . .
RUN cargo build --locked --release --bin myapp

FROM gcr.io/distroless/cc-debian13:nonroot AS runtime
COPY --from=builder /app/target/release/myapp /bin/myapp
ENTRYPOINT ["/bin/myapp"]

Take this Dockerfile as a starting point, but please adapt it to your own project requirements.

cargo-chef keeps dependency builds in a separate Docker layer, so changing your application code does not force all dependencies to rebuild. The important details are: use the same Rust version in all build stages, build with --locked, scope workspace builds with --bin when appropriate, and keep target/, .git/, and editor files out of the build context via .dockerignore. For a deep dive on Docker images and build-time optimization, see Tips For Faster CI Builds.

Keep the Debian suffix explicit instead of using the unversioned tag, and pin by digest if reproducible deploys matter to you. If you deliberately build a fully static musl binary, then gcr.io/distroless/static-debian13:nonroot or even scratch can be a better fit. But don’t mix the two approaches: a glibc-linked binary needs a runtime image that provides the libraries it links against.

A Note On Alpine Base Images

Alpine base images are a well-known alternative, but they use musl instead of glibc. That can expose differences in DNS resolution, TLS/native dependencies, allocator behavior, and crates that assume a glibc-like environment. (1 2 3)

That doesn’t mean Alpine or musl are wrong; just treat them as a deliberate target and test them like one. If you build on Debian and want a small runtime image, distroless cc is usually the less surprising default.

Filesystem sandboxing with Landlock

Even inside a minimal container, your process still has access to any file the container mounts. Landlock is a Linux security module that lets a process restrict its own filesystem access. If your service is ever exploited, the attacker can only reach the files you explicitly allowed. 4

Landlock Is Deployment-Specific

Landlock is Linux-only and requires kernel support. It landed in Linux 5.13, but older enterprise kernels, custom cloud images, or container hosts may not enable it. Check your actual deployment target.

Also apply the sandbox only after you know which files your process needs. If your service executes helper binaries from /usr/bin, reads timezone data from /usr/share/zoneinfo, loads certificates, opens SQLite files, reads config from /etc, or writes uploads to /var/data, those paths must be allowed explicitly. On non-Linux targets, look for equivalent sandboxing mechanisms instead of copying this exact snippet.

use landlock::{
    Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
    RulesetCreatedAttr, ABI,
};

fn sandbox() -> Result<(), Box<dyn std::error::Error>> {
    let abi = ABI::V3;

    Ruleset::default()
        .handle_access(AccessFs::from_read(abi))?
        .create()?
        // Allow read-only access to /etc for config files
        .add_rule(PathBeneath::new(PathFd::new("/etc")?, AccessFs::from_read(abi)))?
        // Allow read+write access to /var/data for your app's data
        .add_rule(PathBeneath::new(
            PathFd::new("/var/data")?,
            AccessFs::from_all(abi),
        ))?
        .restrict_self()?;

    Ok(())
}

fn main() {
    sandbox().expect("failed to apply landlock sandbox");

    // Your service starts here.
    // The service is now restricted to /etc (read) and /var/data (read/write)
    // Any attempt to open /tmp, /home, /proc etc. will be denied!
}

Call sandbox() as early as possible in main, before spawning threads or accepting connections. The restrictions apply to the entire process from that point forward.

The two approaches really go hand in hand:

  • minimal images limit what’s in the container
  • Landlock limits what the process can touch at runtime.

Drop Privileges and Capabilities

Don’t run as root in production, even inside a container. That’s one reason distroless images provide a nonroot user and why the example above uses the :nonroot tag. If your service only needs to listen for HTTP traffic, prefer a high port like 8080 over running as root just to bind to port 80.

Linux capabilities are another useful lever. Instead of giving a process full root privileges, grant only the specific capability it needs, such as CAP_NET_BIND_SERVICE for binding to low ports. If a process needs elevated privileges only during startup, drop them before accepting requests.

The details vary by platform and orchestrator, so treat Linux containers as one concrete setup. For systemd services, Kubernetes, FreeBSD jails, macOS sandboxing, or Windows services, look up the equivalent least-privilege and sandboxing features for that environment.

The big picture is that security hardening is about reducing the surface of things that can go wrong. Every capability your process holds unnecessarily is a liability and everything your code manages that could be delegated to the OS, init system, or container runtime probably should be.

Miri: Detect Unsafe Code Issues

Miri is an interpreter for Rust’s mid-level intermediate representation (MIR) that can detect undefined behavior at runtime.

It works by executing your Rust code in a special environment that tracks memory accesses, pointer validity, and other low-level details to catch issues that the compiler can’t statically guarantee against.

More people should know about Miri, because it is really helpful for hard-to-detect race conditions in multi-threaded or async code; but it can do way more than that, of course. It has already detected a lot of real-world bugs, even in the standard library.

Using it is as simple as running:

rustup +nightly component add miri
cargo +nightly miri test

This will run your tests under Miri’s interpreter.

The docs also describe how to add miri to CI:

  miri:
    name: "Miri"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Miri
        run: |
          rustup toolchain install nightly --component miri
          rustup override set nightly
          cargo miri setup
      - name: Test with Miri
        run: cargo miri test

(Make sure to check the latest instructions in the Miri repo, as the setup process may change over time.)

If you’d like to learn more about Miri, there is a research paper from 2026 that goes into the design and implementation details: Miri: Practical Undefined Behavior Detection for Rust.

Graceful Shutdown Handling

A hardened service doesn’t just crash. Instead, it shuts down gracefully when asked.

Aim to finish in-flight requests, flush your buffers, and release resources cleanly before you exit. The pattern is: listen for shutdown signals, stop accepting new work, drain existing work, then exit.

Frameworks like Axum have built-in support for graceful shutdown. Use it!

The key is handling signals like SIGTERM (sent by Kubernetes, systemd, or docker stop) and SIGINT (Ctrl+C). Here’s a minimal example using tokio-graceful-shutdown, which is a crate that provides good signal handling without much boilerplate. It introduces a concept of “subsystems” that can run concurrently and listen for shutdown requests.

use tokio_graceful_shutdown::{SubsystemHandle, Toplevel};

async fn subsys1(subsys: &mut SubsystemHandle) -> Result<()>
{
    log::info!("Subsystem1 started.");
    subsys.on_shutdown_requested().await;
    log::info!("Subsystem1 stopped.");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    Toplevel::new(async |s: &mut SubsystemHandle| {
        s.start(SubsystemBuilder::new("Subsys1", subsys1))
    })
    .catch_signals()
    .handle_shutdown_requests(Duration::from_millis(1000))
    .await
    .map_err(Into::into)
}

Circuit Breakers for External Dependencies

When an external service (database, API, cache) starts failing, you don’t want to keep hammering it with requests. A circuit breaker tracks failures and “trips” when a threshold is reached.

For production use, consider crates like failsafe or the more actively maintained recloser, which is based on failsafe.

Resource Limits

Unbounded resources are a common source of runtime failures. Everybody who was on call for a production service will tell you this.

Set explicit limits on everything. SREs will thank you for it! Limits make your service more predictable, and they make misconfigurations obvious sooner.

Common things you should limit include:

  • Upper bound on any user input (upload file size, parameter bounds, etc.)
  • request body size
  • timeouts on external calls
  • concurrent connections to external services
  • queue depth for background jobs
  • thread count and DB connection pool size

Here are some examples of how to do this in practice:

Request body size limits

See Axum’s DefaultBodyLimit:

let app = Router::new()
    .route("/", post(|request: Request| async {}))
    .layer(DefaultBodyLimit::max(1024));

Limit queue depth

Bound the number of items in every queue or channel in your system.

use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Job>(1000); // bounded channel, max 1000 pending

Set timeouts on everything external

let client = reqwest::Client::builder()
    .connect_timeout(Duration::from_secs(5))
    .timeout(Duration::from_secs(30))
    .build()?;

Every unbounded resource is a potential DoS vector. Explicit limits turn those catastrophic failures into (annoying but harmless) graceful rejections.

Health Checks and Self-Healing

Ideally, your system should be able to recover from transient failures without human intervention. Health checks let load balancers and orchestrators know when something is wrong, so they can react.

A typical setup has two endpoints, a liveness probe and a readiness probe. The liveness probe checks if the process is alive at all, while the readiness probe checks if the process is healthy enough to handle traffic.

This could honestly be an entire article on its own, but here’s a quick example using Axum to illustrate the concept:

use axum::{routing::get, Router, Json};
use serde::Serialize;

/// Status can be "healthy", "degraded", or "unhealthy"
#[derive(Serialize)]
enum Status {
    // Everything is good, all dependencies are healthy
    Healthy,
    // Some dependencies are degraded,
    // but the service can still handle requests
    Degraded,
    // Critical dependencies are down
    // Don't send any traffic
    Unhealthy,
}

/// This is our health status struct,
/// which we will return as JSON from
/// the readiness probe
#[derive(Serialize)]
struct HealthResponse {
    // Health status of the service
    status: Status,
    // Is the database connection healthy?
    database: bool,
    // Is the cache connection healthy?
    cache: bool,
    // What version of the service is running?
    // (Useful for debugging and monitoring.)
    version: &'static str,
}

// Liveness: "Is the process alive?"
// Should always return 200 if the server can respond at all
async fn liveness() -> &'static str {
    "OK"
}

// Readiness: "Can you handle traffic?"
// Check dependencies before saying yes
async fn readiness(
    db: Extension<DbPool>,
    cache: Extension<CachePool>,
) -> Json<HealthResponse> {
    let db_ok = db.ping().await.is_ok();
    let cache_ok = cache.ping().await.is_ok();

    let status = match (db_ok, cache_ok) {
        (true, true) => Status::Healthy,
        (false, false) => Status::Unhealthy,
        _ => Status::Degraded,
    };

    Json(HealthResponse {
        status,
        database: db_ok,
        cache: cache_ok,
        version: env!("CARGO_PKG_VERSION"),
    })
}

let app = Router::new()
    .route("/health/live", get(liveness))
    .route("/health/ready", get(readiness));

What’s neat about it is that this maps directly to Kubernetes’ health check system:

livenessProbe:
  httpGet:
    path: /health/live
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5

Do we really need both probes? Yes, because they serve different purposes:

  • Kubernetes stops sending traffic (graceful degradation) if the readiness probe fails. It does not yet kill the pod.
  • Kubernetes restarts your pod if the liveness probe fails (it’s self-healing!)

Runtime Hardening Tooling

Finally, here are some more tools that help you catch problems before they hit production:

  • cargo-fuzz – fuzz testing for Rust code
  • honggfuzz – another fuzzer with Rust support
  • cargo-geiger – detects usage of unsafe code
  • cargo-valgrind – runs Valgrind on Rust code to find memory errors
  • cargo-llvm-cov – code coverage via rustc/LLVM source-based instrumentation (-C instrument-coverage). It reports line and region coverage, works with cargo test and cargo nextest, and is a good default for new projects.
  • cargo-tarpaulin – an older Rust coverage tool with strong Cargo and CI ergonomics. On Linux it defaults to a ptrace backend (x86_64 only); LLVM coverage is available through --engine llvm and is the default on macOS and Windows. Useful if its reports fit your workflow, but expect different platform and test-runner edge cases than cargo-llvm-cov.

The tools above help catch undefined behavior, memory safety issues, code coverage gaps, and performance bottlenecks. They are dynamic analysis tools that complement Rust’s static guarantees.

  1. This only holds for unwinding panics. If you compile with panic = "abort", or hit a stack overflow or out-of-memory failure, the whole process exits and join() never gets a chance to return Err.

  2. https://docs.rs/mimalloc-safe/latest/mimalloc_safe/

  3. Data sources I found useful for this topic include this post and this comparison.

  4. This approach would have prevented a vulnerability in Meta’s below crate, a tool for recording and displaying system data like hardware utilization and cgroup information on Linux.

When Rust Gets Ugly

2026-07-17 08:00:00

In workshops I often see people getting frustrated with Rust.

Here’s some of the feedback I hear:

  • “The borrow checker rules make it hard to write code that compiles.”
  • “It’s overwhelming! The syntax is complex with too many symbols and operators. 1
  • “It’s difficult to transition to Rust from .”
  • “The code is not satisfying to read, it feels clunky and verbose.”

From these frustrations, people often conclude that Rust is not for them and quit.

But after programming in Rust for 10 years, I think that your coding style has the biggest impact on how your Rust code will look and feel.

People often say Rust’s syntax is ugly, but I’d argue the syntax is the least interesting thing about Rust. The semantics (the bits and pieces the language provides to express your ideas and how those bits combine to build interesting things) are much more important.

The “ugliness” is only skin-deep; Rust’s beauty lies underneath the surface! And with better semantics comes better syntax.

If you feel like you’re fighting the language, then there’s a chance that the language is speaking to you. It tries to push you into a healthier direction, but if you resist, it will patiently wait until you give in. The moment you start to listen to what Rust is trying to teach you, everything snaps into place; writing Rust feels effortless.

Better semantics unlock nicer syntax. That means, the more you lean into the core mechanics behind Rust (traits, pattern matching, expressions, composition over inheritance, etc.), the more you can build on these concepts to write code that is readable and extensible. The syntax takes a backseat. It gives way to semantics, which are much more important.

If you write Rust like you would write idiomatic code in another language, it will never feel right. You have to embrace how Rust wants you to structure your code. “You can write bad Java code in any language,” is a common saying, and I think it applies here as well.

Good Rust can tick all the boxes: it’s correct, readable, and maintainable. Heck, I’d say it’s pretty, too!

Parsing Things

Let’s consider a simple example: parsing an .env file in Rust. How hard can it be?

DB_HOST=localhost
DB_PORT=5432

API_KEY=my_api_key
LOG_FILE=app.log

The goal is to parse the above content from a file called .env and return a data structure that contains the key-value pairs. Child’s play.

I invite you to write your own version first. Or at least take a second to think about the problem.

Then we’ll refactor a deliberately clunky first attempt into more idiomatic Rust and use that process to extract a general approach: read the standard library, lean on inference and types, handle errors explicitly, and split the problem into smaller parts.

A Painful First Attempt

A Rust learner will sit down and attempt to parse the above file. They might come up with a solution like the one below, which is not too far from what I’ve seen recently.

use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::Path;

// Parse .env file into a HashMap
fn parse_config_file<'a>(path: &'a str) -> HashMap<String, String> {
    let p = Path::new(&path);
    let mut file = File::open(&p).unwrap();
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes).unwrap();

    let s = String::from_utf8_lossy(&bytes).to_string();

    let lines_with_refs: Vec<&'_ str> = s.split('\n').collect();

    let mut idx = 0;
    let mut cfg: HashMap<String, String> = HashMap::new();

    // Iter lines
    while idx < lines_with_refs.len() {
        // Get the line reference and trim it
        let lref = &lines_with_refs[idx];
        let mut l = *lref;
        l = l.trim();

        // Skip empty lines
        if l.len() == 0 {
            idx += 1;
            continue;
        }

        // Skip comments
        if l.chars().next() == Some('#') {
            idx += 1;
            continue;
        }

        // Actual string splitting and trimming
        let parts = l.split('=').collect::<Vec<&str>>();
        let k: &str = parts[0].trim();

        // Check if key is empty
        if k.len() > 0 {
            // We found a valid key. Insert into config
            let v: &str = parts[1].trim();
            cfg.insert(k.to_string(), v.to_string());
        } else {
            // This only happens if the line is malformed, so skip
            println!("Error in line {:?}", parts);
        }

        // Process next line
        idx += 1;
    }

    return cfg;
}

fn main() {
    // Parse a `.env` file in the current directory.
    let config = parse_config_file(".env");
    println!("{config:#?}");
}

The code carries all the hallmarks of a beginner Rust programmer, possibly with a C/C++ background.

  • Littered with unwrap() calls
  • Unnecessary mutability
  • Manual indexing into arrays2
  • Lifetime annotations
  • Cryptic variable names
  • Imperative coding style

Let’s be clear: there are many, many antipatterns in the above code, but the most important observation is that these antipatterns have nothing to do with Rust itself. They are bad coding practices in general.

The learner has not yet fully embraced the ergonomics Rust provides and might be skeptical about performance implications of higher-level abstractions.

We will get back to this code later, but note how Rust makes all of these problems painfully explicit. It looks painful, because it is: the abstractions are too low level for the problem at hand.

Refusal to rethink your coding style in light of Rust’s design principles not only makes your code harder to read; worse, it slows down your learning process.

Down the road, it also leads to business logic bugs in the code, because the compiler can’t help you catch them.

Let Go Of Old Bad Habits

So how can you do better?

The first step is to acknowledge that your existing code goes against Rust’s design principles. It’s a band-aid around outdated ideas from the past still haunting you and holding back your progress. Ugly Rust code is a symptom of old, bad habits.

Based on this realization, we can systematically improve the code. While we go through the refactoring, keep in mind that there is no single “right” way to improve the code, but that it all depends on the context and your goals.

There are a few techniques that can help you write better Rust, some of which we’ve discussed before:

Even just applying these basic techniques, we can get our code into a much better shape.

Before You Continue: Try It Out Yourself!

This is a hands-on exercise. Feel free to paste the above code into your editor and practice refactoring it. Here’s the link to the Rust playground. At the end, there will be a little quiz to see if you found all the edge-cases. I’ll wait here.

Tip #1: Read the Standard Library Documentation

Many common patterns are beautifully handled by the standard library. It is absolutely worth your time to read the documentation and even its source code. For example, you will find that you can get rid of all of this boilerplate:

let p = Path::new(&path);
let mut file = File::open(&p).unwrap();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
let s = String::from_utf8_lossy(&bytes).to_string();

and instead just call read_to_string:

let s = std::fs::read_to_string(path).unwrap();

Tip #2: Use Type Inference

Rust is really good at inferring types. That’s why we don’t need to specify the type of our HashMap explicitly.

let mut cfg: HashMap<String, String> = HashMap::new(); 

becomes

let mut cfg = HashMap::new();

Tip #3: Lean Into the Typesystem

Manual string splitting is error-prone and very much discouraged. The reason is that strings are, in fact, really complicated! There is an outdated assumption that strings are just an array of bytes, but that assumption is ill-defined and dangerous. It is not true for all modern operating systems, including Windows, macOS, and Linux and you should stop thinking about strings that way.

Even in our simple example code from above, string splitting turns out to be a common source of bugs:

let lines_with_refs: Vec<&'a str> = s.split('\n').collect();

This line expects that lines are separated by \n. That’s not true on Windows, where lines are separated by \r\n.

The following line does the right thing on all platforms:

let lines = s.lines();

This returns an iterator over the lines of a string. Knowing that, we can instead iterate over each line:

for line in s.lines() {
    let line = line.trim();

    // ...
}

Note that we shadow line with line.trim(). That is a common practice in Rust and very useful to keep the code clean.

It means we don’t have to come up with a fancy new name for the trimmed line and we also don’t have to fall back to cryptic names like lref or l instead.

By reading the standard library documentation (see tip 1), we learn about some useful methods on strings. So instead of line.len() == 0, we write line.is_empty() now. And line.starts_with("#") is easier on the eye than checking with l.chars().next() == Some('#').

for line in s.lines() {
    let line = line.trim();
    if line.is_empty() || line.starts_with("#") {
        continue;
    }
    // ...
}

Next, let’s tackle this part:

let parts = l.split('=').collect::<Vec<&str>>();

let k: &str = parts[0].trim();
if k.len() > 0 {
    let v: &str = parts[1].trim();
    cfg.insert(k.to_string(), v.to_string());
} else {
    println!("Error in line {:?}", parts);
}

Note how we access parts[0] and parts[1] without checking if these are valid indices. The code only coincidentally works for well-formed inputs. We could add a check to make sure that parts has at least two elements:

if parts.len() >= 2 {
    let k: &str = parts[0].trim();
    if k.len() > 0 {
        let v: &str = parts[1].trim();
        // insert into config
    } else {
        // handle empty key
    }
} else {
    // handle line error
}

But that’s equally clunky and verbose. Fortunately, we don’t have to do any of that if we lean into the typesystem a little more and use pattern matching to destructure the result of split_once:

match line.split_once('=') {
    Some((k, v)) => {
        let k = k.trim();
        if k.is_empty() {
            println!("Error in line with empty key");
        } else {
            let v = v.trim();
            config.insert(k.to_string(), v.to_string());
        }
    }
    None => println!("Error in line: no '=' found"),
}

With that, we end up with an already greatly simplified (but equally performant!) version of the code:

use std::collections::HashMap;
use std::fs::read_to_string;

fn parse_config_file(path: &str) -> HashMap<String, String> {
    let s = read_to_string(path).unwrap();

    let mut config = HashMap::new();
    for line in s.lines() {
        let line = line.trim();

        if line.is_empty() || line.starts_with("#") {
            continue;
        }

        match line.split_once('=') {
            Some((k, v)) => {
                let k = k.trim();
                if k.is_empty() {
                    println!("Error in line with empty key");
                } else {
                    let v = v.trim();
                    config.insert(k.to_string(), v.to_string());
                }
            }
            None => println!("Error in line: no '=' found"),
        }
    }

    config
}

Much nicer. However, to truly embrace Rust, it always helps to take a step back and think about the root of the problem. This is where you can really grow as a programmer.

Tip #4: Don’t Gloss Over Error Handling

We left a few things on the table so far; one obvious one is error handling. How you want to handle invalid lines depends on the business logic, but let’s assume we want to immediately return an error if the file is malformed.

The function itself stays close to what we had; it just returns a Result now and uses ? to bubble up I/O errors:

use std::collections::HashMap;
use std::fs::read_to_string;

fn parse_config_file(path: &str) -> Result<HashMap<String, String>, ParseError> {
    let s = read_to_string(path)?;

    let mut config = HashMap::new();
    for line in s.lines() {
        let line = line.trim();

        if line.is_empty() || line.starts_with("#") {
            continue;
        }

        match line.split_once('=') {
            Some((k, v)) => {
                let k = k.trim();
                if k.is_empty() {
                    return Err(ParseError::InvalidLine(line.to_string()));
                } else {
                    let v = v.trim();
                    config.insert(k.to_string(), v.to_string());
                }
            }
            None => return Err(ParseError::InvalidLine(line.to_string())),
        }
    }

    Ok(config)
}

The supporting ParseError type is mostly mechanical. We implement Display and Error so it plays nicely with the rest of the error ecosystem, and a From<std::io::Error> so that the ? on read_to_string just works:

use std::fmt;
use std::error::Error;

#[derive(Debug)]
enum ParseError {
    InvalidLine(String),
    IoError(std::io::Error),
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseError::InvalidLine(line) => write!(f, "Invalid line format: {line}"),
            ParseError::IoError(err) => write!(f, "I/O error: {err}"),
        }
    }
}

// An empty body is fine here; we lean on the default `source()`.
impl Error for ParseError {}

impl From<std::io::Error> for ParseError {
    fn from(err: std::io::Error) -> Self {
        ParseError::IoError(err)
    }
}

In real projects, I often reach for thiserror to remove most of that boilerplate without hiding the important part: which errors can happen. The same error type becomes:

use thiserror::Error;

#[derive(Debug, Error)]
enum ParseError {
    #[error("Invalid line format: {0}")]
    InvalidLine(String),
    #[error("I/O error: {0}")]
    IoError(#[from] std::io::Error),
}

Granted, our code has gotten quite a bit more verbose again. But in comparison to the original code, the verbosity has a purpose: it marks the various bits and pieces of our code that can go wrong. We have agency to decide how to handle these errors gracefully on the call site rather than silently ignoring them.

Some errors are harder to handle than others. For example, we can choose to skip invalid lines, or we could decide to return a collection of all the errors we encountered while parsing the file. This and more we can express in code now.

Tip #5: Break Up The Problem

The “meat” of the parser is the part that parses individual lines. This is still buried in the single parse_config_file function, which has quite a lot of responsibilities such as reading the file, iterating over lines, and parsing each line. That causes a bunch of problems. For one, we can’t test the line parsing logic in isolation.

Since parsing lines is such a core part of the business logic, let’s make sure it gets the attention it deserves. For starters, let’s move the line parsing logic into its own function.

fn parse_line(line: &str) -> Result<Option<(String, String)>, ParseError> {
    let line = line.trim();

    if line.is_empty() || line.starts_with("#") {
        return Ok(None);
    }

    match line.split_once('=') {
        Some((k, v)) => {
            let k = k.trim();
            if k.is_empty() {
                Err(ParseError::InvalidLine(line.to_string()))
            } else {
                let v = v.trim();
                Ok(Some((k.to_string(), v.to_string())))
            }
        }
        None => Err(ParseError::InvalidLine(line.to_string())),
    }
}

Don’t worry about the ugly function signature for now; we get back to that in a second. In fact, it is a tell-tale sign that we’re still not quite done yet.

In Rust, code that feels “stringy-typed” is usually a sign of a missing abstraction.

In our case, the Result<Option<(String, String)>> type indicates that we are trying to parse a line that may or may not contain a key-value pair, and that parsing can fail. That is a good start for thinking about our missing abstraction.

We need to represent a few different outcomes of parsing a line:

  • An invalid line, represented by the Result
  • An empty line
  • A comment line
  • Finally, a valid key-value pair

Most likely, you would ignore empty lines and comments in your parser, but it’s still a valid outcome of parsing a line. The key insight is that these outcomes are now much more visible and that we have a choice of how to handle these outcomes in our code (in comparison to ignoring them like we did before).

With that in mind, we can define a new enum to represent the different outcomes of parsing a line:

#[derive(Debug)]
enum ParsedLine {
    // This is a valid key-value pair
    KeyValue(KeyValue),
    // A comment line
    Comment(String),
    // An empty line
    Empty,
}

#[derive(Debug)]
struct KeyValue {
    key: String,
    value: String,
}

We’d use it like so:

fn parse_line(line: &str) -> Result<ParsedLine, ParseError> {
    let line = line.trim();

    if line.is_empty() {
        return Ok(ParsedLine::Empty);
    }

    if line.starts_with("#") {
        return Ok(ParsedLine::Comment(line.to_string()));
    }

    match line.split_once('=') {
        Some((k, v)) => {
            let k = k.trim();
            if k.is_empty() {
                Err(ParseError::InvalidLine(line.to_string()))
            } else {
                let v = v.trim();
                Ok(ParsedLine::KeyValue(KeyValue {
                    key: k.to_string(),
                    value: v.to_string(),
                }))
            }
        }
        None => Err(ParseError::InvalidLine(line.to_string())),
    }
}

We could even go one step further and express more of our invariants in the type system. For example, we can make use of the fact that parsing a key-value pair only depends on a single line.

Note

Multiline environment variables exist, so instead of “parsing a single line,” we should say “parsing a single key-value pair.” For now, we will ignore multiline key-value pairs and assume that each line contains at most one key-value pair. However, the solution we are building here is extensible enough to handle multiline key-value pairs in the future.

Since parsing is a fallible operation, we can implement TryFrom for our KeyValue struct:

use std::convert::TryFrom;

impl TryFrom<&str> for KeyValue {
    type Error = ParseError;

    fn try_from(line: &str) -> Result<Self, Self::Error> {
        let line = line.trim();

        if line.is_empty() || line.starts_with("#") {
            return Err(ParseError::InvalidLine(line.to_string()));
        }

        match line.split_once('=') {
            Some((k, v)) => {
                let k = k.trim();
                if k.is_empty() {
                    Err(ParseError::InvalidLine(line.to_string()))
                } else {
                    let v = v.trim();
                    Ok(KeyValue {
                        key: k.to_string(),
                        value: v.to_string(),
                    })
                }
            }
            None => Err(ParseError::InvalidLine(line.to_string())),
        }
    }
}

A natural reaction is to say “this is way too much work for such a simple problem.” And yes, taken in isolation, we are heavily yakshaving here.

Think of it this way: we can now reason about all edge-cases in isolation and errors get handled much closer to the source of the problem. We turned our big ball of mud into a smaller thing that is easier to work with.

The entire Rust standard library is full of abstractions that build on top of each other to help solve bigger problems. I encourage you to embrace that mindset shift. Your code will be more maintainable and extensible.

Our parse_config_file function now becomes much simpler:

fn parse_config_file(path: &str) -> Result<HashMap<String, String>, ParseError> {
    let content = read_to_string(path)?;
    
    let mut config = HashMap::new();
    for line in content.lines() {
        match KeyValue::try_from(line) {
            Ok(kv) => { config.insert(kv.key, kv.value); },
            Err(ParseError::InvalidLine(_)) => continue, // Skip invalid lines
            Err(e) => return Err(e), // Fail on any other error
        }
    }
    
    Ok(config)
}

All we do is create a map of key-value pairs from some input.

Tip #6: Introduce Ergonomic Abstractions

At this stage we might as well convert parse_config_file into a proper struct. And while we’re at it, let’s lift the requirement of passing a file path to the parser and instead accept any type that implements BufRead. This keeps the parser focused on lines instead of bytes. Files, strings, network streams, and test fixtures can all be wrapped in a buffered reader. It makes testing much easier.

use std::collections::HashMap;
use std::convert::TryFrom;
use std::error::Error;
use std::fs::File;
use std::io::{BufRead, BufReader, Cursor};

// `ParseError` is unchanged: the enum plus its
// `Error`, `Display`, and `From<std::io::Error>` impls.
#[derive(Debug)]
enum ParseError {
    InvalidLine(String),
    IoError(std::io::Error),
}
// ...

#[derive(Debug, Clone)]
struct KeyValue {
    key: String,
    value: String,
}

// Same logic as the `TryFrom<&str>` impl above, but taking an owned
// `String` (that's what `BufRead::lines()` yields).
impl TryFrom<String> for KeyValue {
    type Error = ParseError;

    fn try_from(line: String) -> Result<Self, Self::Error> {
        // trim, skip empty/comment lines, then `split_once('=')`
        // ...
    }
}

/// A configuration struct that holds the parsed key-value pairs.
//
// A newtype wrapper around `HashMap<String, String>` so we can change the
// internal representation later without breaking `EnvConfig`'s public API.
#[derive(Debug)]
struct EnvConfig(HashMap<String, String>);

impl EnvConfig {
    // Methods like `new`, `insert`, `get`, `len`,...
}

struct EnvParser;

impl EnvParser {
    fn parse<R: BufRead>(reader: R) -> Result<EnvConfig, ParseError> {
        let mut config = EnvConfig::new(); 

        for line in reader.lines() {
            match line {
                Ok(line_str) => {
                    match KeyValue::try_from(line_str) {
                        Ok(kv) => config.insert(kv),
                        // Skip invalid lines...
                        Err(ParseError::InvalidLine(_)) => continue,
                        // ...but return other errors
                        Err(e) => return Err(e),
                    }
                }
                Err(e) => return Err(ParseError::IoError(e)),
            }
        }

        Ok(config)
    }
    
    // Examples of parsing from different sources
    fn parse_str(input: &str) -> Result<EnvConfig, ParseError> {
        Self::parse(Cursor::new(input))
    }
    
    fn parse_file(path: &str) -> Result<EnvConfig, ParseError> {
        let file = File::open(path)?;
        Self::parse(BufReader::new(file))
    }
}

Example usage:

fn main() -> Result<(), Box<dyn Error>> {
    let env_content = "
        DB_HOST=localhost
        DB_PORT=5432
        
        API_KEY=my_api_key
        LOG_FILE=app.log
    ";
    
    let config = EnvParser::parse_str(env_content)?;
    
    println!("Parsed config entries:");
    for (key, value) in &config.0 {
        println!("{} = {}", key, value);
    }
    
    Ok(())
}

Phew, that was a lot of code, but look how much more maintainable and extensible it is now! We could even go one step further and make the EnvParser struct implement Iterator so that you can iterate over the parsed key-value pairs, but let’s stop here.

What We Achieved

By just following a few key principles, we have transformed our initial parser into a more idiomatic Rust implementation. Now, every part has one clearly defined responsibility:

  • KeyValue is responsible for parsing a single line
  • EnvParser is responsible for parsing the entire input
  • EnvConfig stores the parsed key-value pairs

Sorry that I had to drag you through all of that, but it’s much easier to show than to tell.

I skipped a few intermediate steps, but the idea is always the same: continuously look for wrinkles in the code and move more and more logic into the type system.

Did You Find All The Edge Cases?

Lastly, I’d like to come back to my initial question about edge cases.

Parsing environment files sounds simple on the surface, but that is absolutely not the case! If you haven’t already, I encourage you to write your own implementation of an environment file parser.

And once you’re done, answer the following question: How many of these cases do you handle in your own implementation?

  • Empty lines should be skipped
  • Comment lines starting with # should be skipped
  • Leading and trailing whitespace in keys and values should be trimmed
  • Empty keys like =value should be rejected
  • Empty values like key= should be allowed (with empty string value)
  • Lines without an equals sign should be rejected
  • On Unix, key=value=more is valid and everything after the first = is part of the value
  • Indented lines with leading whitespace should be parsed normally
  • Duplicate keys should overwrite earlier ones with later values
  • Quoted values like key="value" should not include the quotes in the value
  • Escape sequences like key=value\nwith\nnewlines or key=value#notacomment need careful handling
  • Line continuations with backslash for multi-line values are not handled right now
  • The parser should handle non-ASCII Unicode content
  • Files with invalid UTF-8 encoding errors should be handled gracefully

A correct parser would need to handle all these cases. Our improved implementation handles many of these cases, but not all. This just goes to show how easy it is to gloss over details.

Summary

Rust’s beauty is in its semantics and the core mechanics it provides: ownership, borrowing, pattern matching, traits, and so on. If you merely look at its (admittedly foreign) syntax, you overlook the real elegance of the language.

If there is anything that makes Rust “ugly”, it isn’t its syntax but the fact that it doesn’t hide the complexity underneath. Rust values explicitness and you have to deal with the harsh reality that computing is messy. Turns out our assumptions about a program’s execution are often wrong and our mental models are flawed.

Fortunately, we can encapsulate a lot of the complexity behind ergonomic abstractions; it just takes some effort! So don’t worry: once you start to confront your bad habits and look around for better abstractions, Rust stops being ugly.

  1. It turns out that all 48 Rust keywords can fit into 300 characters, so there isn’t a crazy amount to begin with.

  2. That manual indexing hides a latent bug: parts[1] is accessed without checking the length, so the parser happily panics at runtime on any line that doesn’t contain an = (a stray justkey, say). The compiler can’t save you here; you have to remember to handle it yourself.

The Rust Foundation

2026-07-16 08:00:00

Most Rust developers use the language, compiler, package registry, and tooling every day without thinking too much about the organization that helps keep parts of that ecosystem funded and sustainable.

This episode is a re-introduction to the Rust Foundation: what it does, what it does not do, how it relates to the Rust Project, and why that distinction matters for teams using Rust professionally.

My guests are Rebecca Rumbul, Executive Director and CEO of the Rust Foundation, Lori Lorusso, Director of Outreach at the Rust Foundation, and David Wood, Principal Software Engineer at Arm, Compiler Team Co-Lead in the Rust Project, and a Rust Foundation board member. Together we talk about the practical side of ecosystem stewardship: infrastructure, security, interop, maintainer support, governance, corporate membership, open-source funding, and the pressure new technologies like AI put on language ecosystems.

Proudly Supported by CodeCrafters

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.

Show Notes

About the Rust Foundation

The Rust Foundation is an independent non-profit organization supporting the success, sustainability, and positive impact of the Rust programming language. Its work includes funding and supporting ecosystem infrastructure, security and interoperability initiatives, maintainer support, project administration, community programs, events, and collaboration with member companies and donors.

The Foundation is separate from the Rust Project. The Rust Project governs the language, compiler, standard library, and technical direction through its own teams and decision-making processes. The Foundation provides organizational, financial, legal, and operational support around that work, without owning Rust’s technical roadmap.

About the Guests

Rebecca Rumbul is the Executive Director and CEO of the Rust Foundation. She leads the Foundation’s work on organizational strategy, member engagement, sustainability, and support for the broader Rust ecosystem.

Lori Lorusso is Director of Outreach at the Rust Foundation. Her work connects the Foundation with the Rust community, member organizations, trainers, contributors, and companies adopting Rust in production.

David Wood is a Principal Software Engineer at Arm, CE-SW Rust Team Lead, Compiler Team Co-Lead in the Rust Programming Language Project, and a board member of the Rust Foundation. In this episode, David adds the perspective of someone involved in Rust’s technical work as well as Foundation governance.

Links From The Episode

Official Links

Rising Academies

2026-07-02 08:00:00

Most Rust in Production stories are about scale and performance. This one is a story about low-cost phones and patchy mobile connections in Africa, where a student is learning maths over WhatsApp. The whole point is to support hundreds of thousands of students cheaply enough to run at government scale.

My guest is Dylan Brown, a Senior Engineering Manager at Rising Academies, and he comes at Rust from an angle of being the person who signs off on using Rust for a new project.

For Dylan, it’s about what Rust enables: lower compute costs, boring deployments, painless refactors, and code reviews that focus on business logic instead of null checks.

Proudly Supported by CodeCrafters

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.

Show Notes

About Rising Academies

Rising Academies is an education company founded in Sierra Leone in 2014 during the Ebola crisis. It helps governments deliver better learning at scale, working with and through national public school systems. Across seven randomized controlled trials, students in Rising-supported schools have learned on average 2.4x faster each year than their peers. Today Rising supports more than 400,000 students across 1,400 public schools in West and East Africa. Its technology group builds WhatsApp-based tools designed for the realities of limited connectivity and low-cost devices, including Rori (a maths tutor) and Tari (a teacher assistant).

About Dylan Brown

Dylan Brown is a Senior Engineering Manager at Rising Academies, where he leads the development of educational tools deployed across several African countries. He has over a decade in software development and years of experience with conversational systems, from public-transport data in South Africa to a fintech company whose chatbots handled millions in transactions. He now focuses on building trustworthy, accessible technology for resource-constrained environments, and it was Dylan who led the decision to adopt Rust for a new part of Rising’s stack.

Links From The Episode

  • Why I like Rust as an Engineering Leader - Dylan’s blog post about the project
  • axum - The ergonomic, Tokio-based web framework powering the backend
  • sqlx - The async, pure-Rust SQL toolkit with compile-time checked queries
  • cargo-xtask - Instead of writing Python scripts for your project, you can just write Rust scripts
  • pydantic - A Python package that forces you to care about types in Python, coincidentally partially written in Rust
  • Postman - A graphical API client useful for writing end-to-end tests
  • Bruno - An open-source alternative to Postman
  • turn.io - A platform for building WhatsApp-based apps

Official Links