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

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

ClickHouse

2026-06-18 08:00:00

There’s a particular kind of pressure that comes with maintaining software at the very bottom of someone else’s stack. ClickHouse lives in exactly that spot: roughly 1.5 million lines of mostly C++ and tens of millions of tests every single day.

So what happens when you start introducing Rust into a codebase like that? Not as a rewrite, but linked into a C++ server with a CMake build process that has to be reproducible and FIPS compliant? In today’s episode, we get into the messy, interesting reality. We talk about the question of whether the hardest part is Rust the language or Rust the ecosystem.

My guests come at this from two very different angles. Alexey Milovidov is the creator of ClickHouse and its CTO. He started the project back in 2009 and has spent decades thinking about performance, correctness, and what it actually takes to build a production database. Austin Bonander is a Senior Software Engineer at ClickHouse and a renowned open-source maintainer of sqlx. He works on the official Rust client as well as other Rust-related tooling. Together we talk about where Rust fits inside a C++ monolith, what it would take for Rust to earn a rewrite of core components, supply-chain and compliance headaches, and whether Rust is heading for the same accumulation of regrets that every “trendy” language eventually accumulates.

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 ClickHouse

ClickHouse is an open-source, column-oriented OLAP database management system built for real-time analytics over very large datasets. The first version was written in 2009, it went into production in 2012, and it was open-sourced in 2016. Today it’s roughly 1.5 million lines of mostly C++, exercised by tens of millions of automated tests per day and a heavy regime of sanitizers and linters. ClickHouse is known for its raw query performance, and it powers analytics workloads at companies all over the world, from observability and logging platforms to large-scale data warehouses.

About Alexey Milovidov

Alexey Milovidov is the creator of ClickHouse and the CTO of ClickHouse Inc. He started the project in 2009 while working at Yandex and has guided its evolution from an internal analytics tool into one of the most popular open-source databases in the world. He’s spent his career obsessing over performance, correctness, and the kind of low-level engineering discipline it takes to keep a database trustworthy at scale.

About Austin Bonander

Austin Bonander is a Senior Software Engineer at ClickHouse, where he works on the official Rust client as well as other Rust-related tooling. He is a long-time member of the Rust community and a maintainer of sqlx, the async, pure-Rust SQL toolkit. Through that work he has thought deeply about database protocols, API ergonomics, and the long-term maintenance burden of widely used open-source libraries.

Links From The Episode

  • OLAP - A type of database used for analytics, not storing relational data
  • sqlx - The async, pure-Rust SQL toolkit Austin maintains
  • Official /r/rust “Who’s Hiring” thread for job-seekers and job-offerers - Where Austin found the Clickhouse job
  • Clickhouse’s C++ & Rust Journey - Alexeys talk at P99 CONF 2025
  • No-Panic Rust: A Nice Technique for Systems Programming - Using linker checks to guarantee no panic calls in Rust code
  • delta-kernel-rs - A Rust implementation of the Delta Lake kernel, with a non-trivial dependency graph
  • ring - BoringSSL crypto code packaged as a Rust crate
  • H3 - Uber’s Geo Hashing using hexagons, currently used in ClickHouse
  • H3O - The same H3 Geo Hashing algorithm implemented in Rust, with better performance
  • stdx - An attempt at creating an extended standard library with commonly used crates
  • Hyrum’s Law - With enough users, every observable behavior of your system will be depended on by somebody
  • Corrosion - CMake integration for Rust, used to link Rust into a C++ build
  • Cargo - Rust’s build system and package manager, not designed for multi-language monorepos
  • CMake - The build system that dominates the ClickHouse server
  • Poco - The C++ libraries used by the ClickHouse server, without HTTP/2 support
  • hyper - A fast HTTP implementation for Rust

Official Links

Rust Prevents Data Races, Not Race Conditions

2026-06-12 08:00:00

Safe Rust eliminates all data races. What it does not do is prevent race conditions in the broader sense: deadlocks, livelocks, and logic bugs in your synchronization.

What’s the difference?

These two terms get used interchangeably all the time, even by experienced developers, so it’s worth writing down exactly what Rust promises and what it does not.

What Is a Data Race?

To quote the Rustonomicon:

Safe Rust guarantees an absence of data races, which are defined as:

  • two or more threads concurrently accessing a location of memory
  • one or more of them is a write
  • one or more of them is unsynchronized

All three conditions have to hold at once. If every access is a read, there’s no data race. If the accesses are synchronized (say, behind a lock), there’s no data race. A data race is specifically unsynchronized concurrent access where at least one side writes.

This matters because a data race is Undefined Behavior! A data race does not mean you might read a “stale” value. It means the compiler is allowed to do anything like tear a write in half and reorder it.

And you can’t wave this away as a harmless race that happens to work out. As Raph Levien notes in With undefined behavior, anything is possible:

It used to be thought that data races could be classified into “benign” and dangerous categories, but research strongly suggests that the former category doesn’t exist.

In other words, every data race is a real bug! And because it’s Undefined Behavior, the symptom can show up far away from the cause and much later, in the form of a corrupted value, a crash, or a security hole that only appears under heavy load.

For example, here are two threads incrementing the same counter:

use std::thread;

fn main() {
    let mut counter = 0;

    thread::scope(|s| {
        for _ in 0..2 {
            s.spawn(|| {
                counter += 1; // unsynchronized write to shared memory
            });
        }
    });
}

In many languages, the equivalent compiles and runs, and two threads writing to counter at the same time can corrupt it. The result depends on timing, so the bug may not show up until the code runs under load.

In Rust, it doesn’t compile at all:

error[E0499]: cannot borrow `counter` as mutable more than once at a time
  --> ex1_data_race.rs:8:21
   |
 8 |             s.spawn(|| {
   |             -       ^^ `counter` was mutably borrowed here
   |                        in the previous iteration of the loop
 9 |                 counter += 1;
   |                 ------- borrows occur due to use of `counter` in closure

The borrow checker stops you before the program can exist. Two threads both want a mutable reference to counter, and Rust’s core rule is that you can never have two mutable references to the same data at the same time. The data race is impossible because the aliasing it requires is impossible.

This is the point the Nomicon makes:

Data races are prevented mostly through Rust’s ownership system alone: it’s impossible to alias a mutable reference, so it’s impossible to perform a data race.

Key takeaways

  • A data race is a specific thing: concurrent access, at least one write, no synchronization. All three at once.
  • A data race is Undefined Behavior, not just a wrong answer.
  • In purely safe Rust, data races are impossible, because they require aliasing a mutable reference, which the borrow checker forbids.

How Rust Lets You Share State Safely

So how do you increment a counter from two threads correctly? You make the access synchronized, which removes the third condition from the data race definition. Wrap the value in a Mutex, which lets only one thread touch it at a time:

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);

    thread::scope(|s| {
        for _ in 0..2 {
            s.spawn(|| {
                *counter.lock().unwrap() += 1;
            });
        }
    });

    println!("{}", counter.into_inner().unwrap());
}

This compiles, and it always prints 2.

The compiler enforces this through two marker traits, Send and Sync. Roughly: Send means a value can be moved to another thread, and Sync means it can be shared between threads by reference.

A plain i32 can’t be mutated through a shared reference, and a mutable reference can’t be copied across threads. To share and mutate it, you need a type that provides interior mutability while remaining thread-safe (Sync), which is exactly what Mutex<i32> does.

Try to share something that isn’t Sync, like an Rc<T> or a RefCell<T>, and you get a compile error.

(Here the threads can’t outlive counter, so they borrow it directly. If they needed to outlive the scope, say with thread::spawn, you’d wrap it in an Arc to share ownership: Arc<Mutex<T>> is the workhorse for that.)

That’s the whole idea. Rust pushes many concurrency-safety checks from runtime into the type system.

Key takeaways

  • Synchronized access is not a data race, so it’s allowed.
  • A Mutex is the standard way to share mutable state across threads (an Arc<Mutex<T>> when threads outlive their spawning scope).
  • The Send and Sync traits are how the compiler decides what’s safe to move or share between threads. Non-thread-safe types won’t compile in a multi-threaded context.

Race Conditions Are Still Possible

So far we’ve made data races impossible. But a data race is only one kind of concurrency bug. The broader category is a race condition: any bug where the result depends on the timing or interleaving of threads. Rust does not protect you from those.

In the following example, the code moves money out of a shared bank account. That sounds quite scary, but we make sure to lock the Mutex on every access, so there is no data race anywhere in it.

use std::sync::Mutex;
use std::thread;

fn main() {
    // A shared account with $100 in it
    let balance = Mutex::new(100);

    thread::scope(|s| {
        for _ in 0..2 {
            s.spawn(|| {
                // Is there enough money?
                let can_withdraw = *balance.lock().unwrap() >= 100;
                // ...

                // withdraw the money, with a fresh, separate lock.
                if can_withdraw {
                    *balance.lock().unwrap() -= 100;
                }
            });
        }
    });

    println!("final balance: {}", balance.into_inner().unwrap());
}

One possible output is:

final balance: -100

but the output varies per run.

Both threads locked the mutex and checked the balance before, so how is that final balance negative?

There’s a subtle issue: both threads correctly locked the mutex, but they released the lock before they acted on the result of the check. The threads didn’t hold the lock for the entire time. So both threads can check the balance interleaved, seeing $100 before either thread has actually executed the withdrawal, leading both to decide they are cleared to proceed. Then both went ahead and withdrew. The account went negative.

Every individual access was synchronized, so the borrow checker is perfectly happy. The bug is that the check and the act are two separate critical sections. Between them, the world can change. This is a race condition (specifically a TOCTOU, time-of-check-to-time-of-use bug), and no type system can catch it for you, because the correctness depends on what you intended the locking to mean.

Once you understand this, the fix is simply to make the check and the act one atomic operation, holding the lock across both:

let mut balance = balance.lock().unwrap();
if *balance >= 100 {
    *balance -= 100;
}

You might think that this code is identical to the original, but it’s not. lock() returns a MutexGuard, and here we keep it in the balance binding instead of dropping it right away. The lock stays held for as long as that guard is alive, which (like any other value in Rust) means until the end of its scope. So the check and the withdrawal now happen inside one critical section, and no other thread can squeeze in between them. When balance goes out of scope, its Drop implementation releases the lock automatically.

In the original code, each *balance.lock().unwrap() produced a temporary guard that was dropped immediately at the end of that statement, so the lock was released the instant each access finished, leaving a gap for a race condition.

The compiler can’t know which behavior you wanted. As the Nomicon puts it:

It is considered “safe” for Rust to get deadlocked or do something nonsensical with incorrect synchronization.

Key takeaways

  • A race condition is a logic bug where the outcome depends on timing or thread interleaving.
  • You can have a race condition with zero data races. The withdrawal code locks correctly everywhere and still corrupts its own state.
  • Holding a lock per-access is not enough. The critical section has to cover the whole logical operation, or the invariant can break in the gap.

Deadlocks Also Compile Just Fine

If incorrect locking is “safe,” then so is locking that never finishes. The simplest example: lock the same mutex twice on one thread. Rust’s standard Mutex is not reentrant, so the second lock() waits for a guard that will never be released.

use std::sync::Mutex;

fn main() {
    let data = Mutex::new(0);

    let _first = data.lock().unwrap();
    println!("got the first lock");

    // std's Mutex is not reentrant: this second lock waits
    // forever for a guard that will never be dropped.
    let _second = data.lock().unwrap();
    println!("got the second lock"); // never reached
}

This compiles without a single warning. Running it:

got the first lock
[hangs forever]

It prints the first line and then waits indefinitely. The borrow checker has nothing to say, because nothing here is unsafe in the memory sense. A deadlocked program isn’t reading bad memory; it’s just not making progress.

Why isn’t Mutex reentrant in the first place?

A reentrant mutex would let you lock it again while you already hold it. The trouble is that Rust’s Mutex::lock hands you a &mut T to the protected data. If re-locking were allowed, you could call lock() a second time and get a second &mut T to the same value while the first is still live, which is exactly the aliasing the borrow checker exists to prevent.

So a reentrant mutex in Rust can only safely hand out a shared &T, not &mut T. That’s much less useful, since you usually want a Mutex precisely to mutate the value inside. (There might also be a historical reason: std’s Mutex started life as a thin wrapper over OS primitives, and some of those aren’t reentrant either.)

If you actually need reentrancy, parking_lot::ReentrantMutex provides it, and it gives out &T only. You pair it with Cell or RefCell for the actual mutation. See this forum thread for more info.

Real deadlocks are usually subtler than this. The textbook version is two threads that grab two locks in opposite orders, each waiting on the lock the other holds. But the general problem is that liveness (the program keeps making progress) is not something Rust’s safety guarantees cover. Safety is about not doing the wrong thing; it says nothing about eventually doing the right thing.

Key takeaways

  • A deadlock is a race condition where threads wait on each other (or themselves) forever.
  • std::sync::Mutex is not reentrant. Locking it twice on the same thread deadlocks.
  • Rust guarantees memory safety, not liveness. A program that hangs is still a “safe” program as far as the compiler is concerned.

Atomics Are Not a Magic Bullet Either

You might think the bank-account bug was really about Mutex: drop the lock, reach for lock-free atomics, and the problem goes away. It doesn’t. The check-then-act trap has nothing to do with locks. It’s about composing operations, and atomics compose just as badly.

Atomics are synchronized by definition, so each individual operation is data-race-free. But “each operation is atomic” is not the same as “my sequence of operations is atomic”, which is exactly the gap we just saw with the mutex.

Here four threads each do 100,000 increments, but the increment is split into a separate load and store:

use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

fn main() {
    let counter = AtomicU64::new(0);

    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..100_000 {
                    // Two independent atomic operations are not atomic together! 
                    let current = counter.load(Ordering::SeqCst);
                    counter.store(current + 1, Ordering::SeqCst);
                }
            });
        }
    });

    println!("expected: 400000");
    println!("got:      {}", counter.into_inner());
}

Two example runs with two different (wrong) answers:

expected: 400000
got:      305352
expected: 400000
got:      168582

Every load and every store was a properly synchronized atomic operation. No data race occurred. But two threads can both load the same value, both add one, and both store it back, and one of the increments vanishes. It’s the bank account again: the gap this time sits between two atomic operations instead of between two locked sections. This is a lost update, which is, once again, a race condition. 1

Notice that we’re using SeqCst, the strongest memory ordering Rust provides. The bug still occurs because the problem isn’t memory ordering; it’s that the increment is split into two separate operations.

The fix is to collapse the two steps into a single indivisible operation. With a lock, that meant holding the guard across both. With atomics, it means a single read-modify-write operation, fetch_add, which does the load-add-store in one step:

counter.fetch_add(1, Ordering::SeqCst);

With that one change, the program prints 400000 every time.

This is the same check-then-act trap as the bank account, with no lock in sight; the problem was never about Mutex.

Key takeaways

  • Atomicity has a scope. The hardware guarantees the individual operation is atomic; making your logical operation atomic is still your job.
  • Atomic operations are individually data-race-free, but composing several of them is not automatically atomic. load then store is two operations, and another thread can slip in between them.
  • The fix mirrors the lock case: make the whole logical operation indivisible. Reach for fetch_add and friends instead of a separate load and store.

So What Does Rust Actually Guarantee?

Safe Rust eliminates data races by design. A program with a data race does not compile. It’s a stronger guarantee than what runtime detectors like Go’s -race or C/C++’s ThreadSanitizer give you, because those only catch races that actually execute during a test run.

Safe Rust does not prevent race conditions in general. Deadlocks, livelocks, lost updates, and check-then-act bugs all compile cleanly and can still produce wrong answers or hang.2

Geo-ant, writing up a comparison of common C++ bugs against Rust, sums up the whole distinction in one line:

Rust does prevent data races and on the other hand you can still deadlock all you want.

The reason this distinction matters, and not just pedantically, is that it tells you where to spend your attention. You can stop worrying about torn reads and forgotten locks corrupting memory; the compiler has that. What’s left is the hard part of concurrency: making sure your critical sections cover your invariants, that your lock ordering is consistent, and that your logical operations are as atomic as you think they are.

Rust holds an enormous amount for you, and what remains is the part that lives in your intent, which no type system can read.

If you want to go deeper on the concurrency side of this, read Rust Atomics and Locks by Mara Bos. It’s free online.

Where to go from here

Want to get concurrency right in your Rust codebase, including the traps covered in this post?

  • For your team. Training, code review, and architecture support to ship Rust with confidence.
  • For yourself. 1-on-1 mentorship to build deep Rust intuition at your own pace.
  1. Fun fact: the count indicates how many increments were lost,i.e., the total number of individual increments that vanished because threads interleaved, read a stale value, and overwrote each other’s progress. So in the first run, 94,648 increments were lost, and in the second run, 231,418 were lost; that’s a percentage of 23.66% and 57.85%, respectively, which is a huge difference just from the timing of how the threads interleaved.

  2. In the context of this article, I treat “data race” and “race condition” as two separate things, which is a useful simplification but not the full picture. The two concepts overlap heavily (many race conditions are caused by data races), yet neither is contained in the other: you can have a race condition with no data race (the bank-balance example above locks every access correctly and still loses money). Under some definitions, you can even construct examples where a data race exists but no observable program behavior depends on it (two threads racing to set an “account was touched” flag that nothing depends on). I recommend reading John Regehr post titled Race Condition vs. Data Race.

Veo

2026-06-04 08:00:00

I don’t know about you, but to me there are few things as interesting as the hardware/software interface: the point where carefully written code meets the messy, physical world of sensors, lenses, and real-time constraints. It’s where a clever abstraction either holds up or falls apart the moment a real signal hits it.

That makes Veo a perfect guest. The Copenhagen-based company builds AI-powered cameras that record and analyze sports matches, from grassroots football pitches to professional clubs, and then turn hours of raw footage into something coaches and players can actually use: automatic highlights, player tracking, and match analysis. To get there, they have to capture panoramic video on a custom camera, follow the action without an operator, and crunch an enormous amount of data, reliably and at scale.

My guests sit on both sides of that interface. Anders Hellerup Madsen works close to the metal on the camera itself, on the embedded firmware and the GStreamer media pipeline that turns raw sensor data into video. Gorm Casper works further up the stack, on the backend that ingests, processes, and analyzes those matches in Rust. Together we talk about where Rust fits across that whole journey, the trade-offs of doing media and computer vision work in a systems language, and what convinced a sports-tech company to bet on Rust for the parts that absolutely cannot fall over.

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 Veo

Veo (Veo Technologies) is a Danish sports-tech company, headquartered in Copenhagen, that builds AI-powered cameras and a video platform for recording and analyzing matches. Instead of relying on a human camera operator, a Veo camera captures the entire pitch in panoramic video and uses computer vision to automatically follow the ball, generate highlights, and produce analysis that coaches, players, and clubs can use. What started in football has grown into a platform used by tens of thousands of teams across the world, spanning many sports, from amateur clubs to professional organizations.

About Anders Hellerup Madsen

Anders Hellerup Madsen is a Senior Software Engineer at Veo, where he works on embedded firmware and on the GStreamer-based media processing pipeline that runs on the Veo camera. He is also a GStreamer contributor.

About Gorm Casper

Gorm Casper is a Software Engineer at Veo. After many years working on the frontend, he now spends his time on the backend, writing Rust. He holds a Master’s in Digital Design & Communication from the IT University of Copenhagen.

Links From The Episode

Official Links