MoreRSS

site iconMatthias EndlerModify

A Rust developer and open-source maintainer with 20 years of experience in software development.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Matthias Endler

Thinking in Expressions

2025-01-21 08:00:00

Rust’s focus on expressions is an underrated aspect of the language. Code feels more natural to compose once you embrace expressions as a core mechanic in Rust. I would go as far as to say that expressions shaped the way I think about control flow in general.

“Everything is an expression” is a bit of an exaggeration, but it’s a useful mental model while you internalize the concept.[1]

But what’s so special about them?

Expressions produce values, statements do not.

The difference between expressions and statements can easily be dismissed as a minor detail. Underneath the surface, though, the fact that expressions return values has a profound impact on the ergonomics of a language.

In Rust, most things produce a value: literals, variables, function calls, blocks, and control flow statements like if, match, and loop. Even & and * are expressions in Rust.

Expressions In Rust vs other languages

Rust inherits expressions from its functional roots in the ML family of languages; they are not so common in other languages. Go, C++, Java, and TypeScript have them, but they pale in comparison to Rust.

In Go, for example, an if statement is… well, a statement and not an expression. This has some surprising side-effects. For example, you can’t use if statements in a ternary expression like you would in Rust:

// This is not valid Go code!
var x = if condition { 1 } else { 2 };

Instead, you’d have to write a full-blown if statement along with a slightly unfortunate upfront variable declaration:

var x int
if condition {
  x = 1
} else {
  x = 2
}

Since if is an expression in Rust, using it in a ternary expression is perfectly normal.

let x = if condition { 1 } else { 2 };

That explains the absence of the ternary operator in Rust (i.e. there is no syntax like x = condition ? 1 : 2;). No special syntax is needed because if is comparably concise.

Also note that in comparison to Go, our variable x does not need to be mutable. As we will see, Rust’s expressions often lead to less mutable code.

In combination with pattern matching, expressions in Rust become even more powerful:

let (a, b) = if condition { ("first", true) } else { ("second", false) };

Here, the left side of the assignment (a, b) is a pattern that destructures the tuple returned by the if-else expression.

What if you deal with more complex control flow? That’s not a problem. match is an expression, too. It is common to assign the result of a match expression to a variable.

let color = match duck {
    Duck::Huey => "Red",
    Duck::Dewey => "Blue",
    Duck::Louie => "Green",
};

Combining match and if Expressions

Let’s say you want to return a duck’s color, but you want to return the correct color based on the year. (In the early Disney comics, the nephews were wearing different colors.)

let color = match duck {
    // In early comic books, the
    // ducks were colored randomly
    _ if year < 1980 => random_color(),
    
    // In the early 80s, Huey's cap was pink
    Duck::Huey if year < 1982 => "Pink",
    
    // Since 1982, the ducks have dedicated colors
    Duck::Huey => "Red",
    Duck::Dewey => "Blue",
    Duck::Louie => "Green",
};

Neat, right? You can combine match and if expressions to create complex logic in a few lines of code.

Note: those ifs are called match arm guards, and they really are full-fledged if expressions. You can put anything in there just like in a regular if.

Lesser known facts about expressions

break is an expression

You can return a value from a loop with break:

let foo = loop { break 1 };
// foo is 1

More commonly, you’d use it like this:

let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2;
    }
};
// result is 20

dbg!() returns the value of the inner expression

You can wrap any expression with dbg!() without changing the behavior of your code (aside from the debug output).

let x = dbg!(compute_complex_value());

Real-World Refactoring With Expressions

So far, I showed you some fancy expression tricks, but how do you apply this in practice?

To illustrate this, imagine you have a Config struct that reads a configuration file from a given path:

/// Configuration for the application
pub struct Config {
    config_path: PathBuf,
}

/// Creates a new Config with the given path
///
/// The path is resolved against the home directory if relative.
/// Validates that the path exists and has the correct extension.
impl Config {
    pub fn with_config_path(path: PathBuf) -> Result<Self, std::io::Error> {
        todo!()
    }
}

Here’s how you might implement the with_config_path method in an imperative style:

impl Config {
    pub fn with_config_path(path: PathBuf) -> Result<Self, std::io::Error> {
        // First determine the base path
        let mut config_path;
        if path.is_absolute() {
            config_path = path;
        } else {
            let home = get_home_dir();
            if home.is_none() {
                return Err(io::Error::new(
                    io::ErrorKind::NotFound,
                    "Home directory not found",
                ));
            }
            config_path = home.unwrap().join(path);
        }

        // Do validation
        if !config_path.exists() {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                "Config path does not exist",
            ));
        }

        if config_path.is_file() {
            let ext = config_path.extension();
            if ext.is_none() {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidInput,
                    "Config file must have .conf extension",
                ));
            }
            if ext.unwrap().to_str() != Some("conf") {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidInput,
                    "Config file must have .conf extension",
                ));
            }
        }

        return Ok(Self { config_path });
    }
}

There are a few things we can improve here:

  • The code is quite imperative
  • Lots of temporary variables
  • Explicit mutation with mut
  • Nested if statements
  • Manual unwrapping with is_none()/unwrap()

Tip 1: Remove the unwraps

It’s always a good idea to examine unwrap() calls and find safer alternatives. While we “only” have two unwrap() calls here, both point at flaws in our design. Here’s the first one:

let mut config_path;
if path.is_absolute() {
    config_path = path;
} else {
    let home = get_home_dir();
    if home.is_none() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            "Home directory not found",
        ));
    }
    config_path = home.unwrap().join(path);
}

We know that home is not None when we unwrap it, because we checked it right above. But what if we refactor the code? We might forget the check and introduce a bug.

This can be rewritten as:

let config_path = if path.is_absolute() {
    path
} else {
    let home = get_home_dir().ok_or_else(|| io::Error::new(
        io::ErrorKind::NotFound,
        "Home directory not found",
    ))?;
    home.join(path)
};

Or, if we introduce a custom error type:

let config_path = if path.is_absolute() {
    path
} else {
    let home = get_home_dir().ok_or_else(|| ConfigError::HomeDirNotFound)?;
    home.join(path)
};

The other unwrap is also unnecessary and makes the happy path harder to read. Here is the original code:

if config_path.is_file() {
    let ext = config_path.extension();
    if ext.is_none() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "Config file must have .conf extension",
        ));
    }
    if ext.unwrap().to_str() != Some("conf") {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "Config file must have .conf extension",
        ));
    }
}

We can rewrite this as:

if config_path.is_file() {
    let Some(ext) = config_path.extension() else {
        return Err(...);
    }
    if ext != "conf" {
        return Err(...);
    }
}

Or we return early to avoid the nested if:

if !config_path.is_file() {
    return Err(...);
}

let Some(ext) = config_path.extension() else {
    return Err(...);
}

if ext != "conf" {
    return Err(io::Error::new(...));
}

(Playground)

Tip 2: Remove the muts

Usually, my next step is to get rid of as many mut variables as possible.

Note how there are no more mut keywords after our first refactoring. This is a typical pattern in Rust: often when we get rid of an unwrap(), we can remove a mut as well.

Nevertheless, it is always a good idea to look for all mut variables and see if they are really necessary.

Tip 3: Remove the explicit return statements

The last expression in a block is implicitly returned and that return is an expression itself, so you can often get rid of explicit return statements. In our case that means:

return Ok(Self { config_path });

becomes

Ok(Self { config_path })

Another simple heuristic is to hunt for returns and semicolons in the middle of your code. These are like “seams” in our program; stop signs, which break the natural data flow. Almost effortlessly, removing these blockers often improves the flow; it’s like magic.

For example, the above validation code can also be written without returns:

match config_path {
    path if !path.is_file() => Err(io::Error::new(
        io::ErrorKind::InvalidInput,
        "Config path is not a file",
    )),
    path if path.extension() != Some(OsStr::new("conf")) => Err(io::Error::new(
        io::ErrorKind::InvalidInput,
        "Config file must have .conf extension",
    )),
    _ => Ok(())
}

I like that, because we avoid one error message duplication and all conditions start on the left. Whether you prefer that over let-else is a matter of taste. [2]

Don’t take it too far

Remember when I said “everything is an expression”? Don’t take this too far or people will stop inviting you to dinner parties.

It’s fun to know that you could use then_some, unwrap_or_else, and map_or to chain expressions together, but don’t use them to show off.

The below code is correct, but the combinators get in the way of readability. Now it feels more like a Lisp program than Rust code.

impl Config {
    pub fn with_config_path(path: PathBuf) -> Result<Self, io::Error> {
        (if path.is_absolute() {
            Ok(path)
        } else {
            get_home_dir()
                .ok_or_else(/* error */)
                .map(|home| home.join(path))
        })
        .and_then(|config_path| {
            (!config_path.exists())
                .then_some(Err(/* error */))
                .unwrap_or_else(|| {
                    config_path
                        .is_file()
                        .then(|| {
                            (!config_path
                                .extension()
                                .map_or(false, |ext| ext == "conf"))
                                .then_some(Err(/* error */))
                                .unwrap_or(Ok(()))
                        })
                        .unwrap_or(Ok(()))
                        .map(|_| config_path)
                })
        })
        .map(|config_path| Self { config_path })
    }
}

Keep your friends and colleagues in mind when writing code. Find a balance between expressiveness and readability.

Conclusion

If you find that your code doesn’t feel idiomatic, see if expressions can help. They tend to guide you towards more ergonomic Rust code.

Once you find the right balance, expressions are a joy to use – especially in smaller context where data flow is key. The “trifecta” of iterators, expressions, and pattern matching is the foundation of data transformations in Rust. I wrote a complementary article about iterators here.

Of course, it’s not forbidden to mix expressions and statements! For example, I personally like to use let-else statements when it makes my code easier to understand. If you’re unsure about whether using an expression is worth it, seek feedback from someone less familiar with Rust. If they look confused, you probably tried to be too clever.

Now, try to refactor some code to train that muscle.


  1. The Rust Reference puts it like this: “Rust is primarily an expression language. This means that most forms of value-producing or effect-causing evaluation are directed by the uniform syntax category of expressions. Each kind of expression can typically nest within each other kind of expression, and rules for evaluation of expressions involve specifying both the value produced by the expression and the order in which its sub-expressions are themselves evaluated.”

  2. By the way, let-else is not an expression, but a statement. That’s because the else branch doesn’t produce a value. Instead, it moves the “failure” case into the body block, while allowing the “success” case to continue in the surrounding context without additional nesting. I recommend reading the RFC for more details.

Prototyping in Rust

2025-01-15 08:00:00

Programming is an iterative process - as much as we would like to come up with the perfect solution from the start, it rarely works that way.

Good programs often start as quick prototypes. The bad ones stay prototypes, but the best ones evolve into production code.

Whether you’re writing games, CLI tools, or designing library APIs, prototyping helps tremendously in finding the best approach before committing to a design. It helps reveal the patterns behind more idiomatic code.

For all its explicitness, Rust is surprisingly ergonomic when iterating on ideas. Contrary to popular belief, it is a joy for building prototypes.

You don’t need to be a Rust expert to be productive - in fact, many of the techniques we’ll discuss specifically help you sidestep Rust’s more advanced features. If you focus on simple patterns and make use of Rust’s excellent tooling, even less experienced Rust developers can quickly bring their ideas to life.

Things you’ll learn

  • How to prototype rapidly in Rust while keeping its safety guarantees
  • Practical techniques to maintain a quick feedback loop
  • Patterns that help you evolve prototypes into production code

Why People Think Rust Is Not Good For Prototyping

The common narrative goes like this:

When you start writing a program, you don’t know what you want and you change your mind pretty often. Rust pushes back when you change your mind because the type system is very strict. On top of that, getting your idea to compile takes longer than in other languages, so the feedback loop is slower.

I’ve found that developers not yet too familiar with Rust often share this preconception. These developers stumble over the strict type system and the borrow checker while trying to sketch out a solution. They believe that with Rust you’re either at 0% or 100% done (everything works and has no undefined behavior) and there’s nothing in between.

Here are some typical misbeliefs:

  1. “Memory safety and prototyping just don’t go together.”
  2. “Ownership and borrowing take the fun out of prototyping.”
  3. “You have to get all the details right from the beginning.”
  4. “Rust always requires you to handle errors.”

These are all common misconceptions and they are not true.

It turns out you can avoid all of these pitfalls and still get a lot of value from prototyping in Rust.

Problems with Prototyping in Other Languages

If you’re happy with a scripting language like Python, why bother with Rust?

That’s a fair question! After all, Python is known for its quick feedback loop and dynamic type system, and you can always rewrite the code in Rust later.

Yes, Python is a great choice for prototyping. But I’ve been a Python developer for long enough to know that I’ll very quickly grow out of the “prototype” phase -– which is when the language falls apart for me.

One thing I found particularly challenging in Python was hardening my prototype into a robust, production-ready codebase. I’ve found that the really hard bugs in Python are often type-related: deep down in your call chain, the program crashes because you just passed the wrong type to a function. Because of that, I find myself wanting to switch to something more robust as soon as my prototype starts to take shape.

The problem is that switching languages is a huge undertaking – especially mid-project. Maybe you’ll have to maintain two codebases simultaneously for a while. On top of that, Rust follows different idioms than Python, so you might have to rethink the software architecture. And to add insult to injury, you have to change build systems, testing frameworks, and deployment pipelines as well.

Wouldn’t it be nice if you could use a single language for prototyping and production?

What Makes Rust Great for Prototyping?

Using a single language across your entire project lifecycle is great for productivity. Rust scales from proof-of-concept to production deployment and that eliminates costly context switches and rewrites. Rust’s strong type system catches design flaws early, but we will see how it also provides pragmatic escape hatches if needed. This means prototypes can naturally evolve into production code; even the first version is often production-ready.

But don’t take my word for it. Here’s what Discord had to say about migrating from Go to Rust:

Remarkably, we had only put very basic thought into optimization as the Rust version was written. Even with just basic optimization, Rust was able to outperform the hyper hand-tuned Go version. This is a huge testament to how easy it is to write efficient programs with Rust compared to the deep dive we had to do with Go. – From Why Discord is switching from Go to Rust

What A Solid Rust Prototyping Workflow Looks Like

If you start with Rust, you get a lot of benefits out of the box: a robust codebase, a strong type system, and built-in linting.

All without having to change languages mid-project! It saves you the context switch between languages once you’re done with the prototype.

flow

Python has a few good traits that we can learn from:

  • fast feedback loop
  • changing your mind is easy
  • it’s simple to use (if you ignore the edge cases)
  • very little boilerplate
  • it’s easy to experiment and refactor
  • you can do something useful in just a few lines
  • no compilation step

The goal is to get as close to that experience in Rust as possible while staying true to Rust’s core principles. Let’s make changes quick and painless and rapidly iterate on our design without painting ourselves into a corner. (And yes, there will still be a compilation step, but hopefully, a quick one.)

Tips And Tricks For Prototyping In Rust

Use simple types

Even while prototyping, the type system is not going away. There are ways to make this a blessing rather than a curse.

Use simple types like i32, String, Vec in the beginning. We can always make things more complex later if we have to – the reverse is much harder.

Here’s a quick reference for common prototype-to-production type transitions:

Prototype Production When to switch
String &str When you need to avoid allocations or store string data with a clear lifetime
Vec<T> &[T] When the owned vector becomes too expensive to clone or you can’t afford the heap
Box<T> &T or &mut T When Box becomes a bottleneck or you don’t want to deal with heap allocations
Rc<T> &T When the reference counting overhead becomes too expensive or you need mutability
Arc<Mutex<T>> &mut T When you can guarantee exclusive access and don’t need thread safety

These owned types sidestep most ownership and lifetime issues, but they do it by allocating memory on the heap - just like Python or JavaScript would.

You can always refactor when you actually need the performance or tighter resource usage, but chances are you won’t.[1]

Make use of type inference

Rust is a statically, strongly typed language. It would be a deal-breaker to write out all the types all the time if it weren’t for Rust’s type inference.

You can often omit (“elide”) the types and let the compiler figure it out from the context.

let x = 42;
let y = "hello";
let z = vec![1, 2, 3];

This is a great way to get started quickly and defer the decision about types to later. The system scales well with more complex types, so you can use this technique even in larger projects.

let x: Vec<i32> = vec![1, 2, 3];
let y: Vec<i32> = vec![4, 5, 6];

// From the context, Rust knows that `z` needs to be a `Vec<i32>`
// The `_` is a placeholder for the type that Rust will infer
let z = x.into_iter().chain(y.into_iter()).collect::<Vec<_>>();

Here’s a more complex example which shows just how powerful Rust’s type inference can be:

use std::collections::HashMap;

// Start with some nested data
let data = vec![
    ("fruits", vec!["apple", "banana"]),
    ("vegetables", vec!["carrot", "potato"]),
];

// Let Rust figure out this complex transformation
// Can you tell what the type of `categorized` is?
let categorized = data
    .into_iter()
    .flat_map(|(category, items)| {
        items.into_iter().map(move |item| (item, category))
    })
    .collect::<HashMap<_, _>>();

// categorized is now a HashMap<&str, &str> mapping items to their categories
println!("What type is banana? {}", categorized.get("banana").unwrap());

(Playground)

It’s not easy to visualize the structure of categorized in your head, but Rust can figure it out.

Use the Rust playground

You probably already know about the Rust Playground. The playground doesn’t support auto-complete, but it’s still great when you’re on the go or you’d like to share your code with others.

I find it quite useful for quickly jotting down a bunch of functions or types to test out a design idea.

Use unwrap Liberally

It’s okay to use unwrap in the early stages of your project. An explicit unwrap is like a stop sign that tells you “here’s something you need to fix later.” You can easily grep for unwrap and replace it with proper error handling later when you polish your code. This way, you get the best of both worlds: quick iteration cycles and a clear path to robust error handling. There’s also a clippy lint that points out all the unwraps in your code.

use std::fs;
use std::path::PathBuf;

fn main() {
    // Quick and dirty path handling during prototyping
    let home = std::env::var("HOME").unwrap();
    let config_path = PathBuf::from(home).join(".config").join("myapp");
    
    // Create config directory if it doesn't exist
    fs::create_dir_all(&config_path).unwrap();
    
    // Read the config file, defaulting to empty string if it doesn't exist
    let config_file = config_path.join("config.json");
    let config_content = fs::read_to_string(&config_file)
        .unwrap_or_default();
    
    // Parse the JSON config
    let config: serde_json::Value = if !config_content.is_empty() {
        serde_json::from_str(&config_content).unwrap()
    } else {
        serde_json::json!({})
    };
    
    println!("Loaded config: {:?}", config);
}

See all those unwraps? To more experienced Rustaceans, they stand out like a sore thumb – and that’s a good thing!

Compare that to languages like JavaScript which can throw exceptions your way at any time. It’s much harder to ensure that you handle all the edge-cases correctly. At the very least, it costs time. Time you could spend on more important things.

While prototyping with Rust, you can safely ignore error handling and focus on the happy path without losing track of improvement areas.

Add anyhow to your prototypes

I like to add anyhow pretty early during the prototyping phase, to get more fine-grained control over my error handling. This way, I can use bail! and with_context to quickly add more context to my errors without losing momentum. Later on, I can revisit each error case and see if I can handle it more gracefully.

use anyhow::{bail, Context, Result};

// Here's how to use `with_context` to add more context to an error
let home = std::env::var("HOME")
    .with_context(|| "Could not read HOME environment variable")?;

// ...alternatively, use `bail` to return an error immediately 
let Ok(home) = std::env::var("HOME") else {
    bail!("Could not read HOME environment variable");
};

The great thing about anyhow is that it’s a solid choice for error handling in production code as well, so you don’t have to rewrite your error handling logic later on.

Use a good IDE

There is great IDE support for Rust.

IDEs can help you with code completion and refactoring, which keep you in the flow and help you write code faster. Autocompletion is so much better with Rust than with dynamic languages because the type system gives the IDE a lot more information to work with.

As a corollary to the previous section, be sure to use enable inlay hints (or inline type hints) in your editor. This way, you can quickly see the inferred types right inside your IDE and make sure the types match your expectations. There’s support for this in most Rust IDEs, including RustRover and Visual Studio Code.

Inlay hints in Rust Rover

Use bacon for quick feedback cycles

Rust is not a scripting language; there is a compile step!

However, for small projects, the compile times are negligible. Unfortunately, you have to manually run cargo check every time you make a change or use rust-analyzer in your editor to get instant feedback.

To fill the gap, you can use external tools like bacon which automatically recompiles and runs your code whenever you make a change. This way, you can get almost the same experience as with a REPL in, say, Python or Ruby.

The setup is simple:

# Install bacon
cargo install --locked bacon

# Run bacon in your project directory
bacon

And just like that, you can get some pretty compilation output alongside your code editor.

bacon

Oh, and in case you were wondering, cargo-watch was another popular tool for this purpose, but it’s since been deprecated.

cargo-script is awesome

Did you know that cargo can also run scripts?

For example, put this into a file called script.rs:

#!/usr/bin/env cargo +nightly -Zscript

fn main() {
    println!("Hello prototyping world");
}

Now you can make the file executable with chmod +x script.rs and run it with ./script.rs which it will compile and execute your code! This allows you to quickly test out ideas without having to create a new project. There is support for dependencies as well.

At the moment, cargo-script is a nightly feature, but it will be released soon on stable Rust. You can read more about it in the RFC.

Don’t worry about performance

You have to try really really hard to write slow code in Rust. Use that to your advantage: during the prototype phase, try to keep the code as simple as possible.

I gave a talk titled “The Four Horsemen of Bad Rust Code” where I argue that premature optimization is one of the biggest sins in Rust.

Especially experienced developers coming from C or C++ are tempted to optimize too early.

Rust makes code perform well by default - you get memory safety at virtually zero runtime cost. When developers try to optimize too early, they often run up against the borrow checker by using complex lifetime annotations and intricate reference patterns in pursuit of better performance. This leads to harder-to-maintain code that may not actually run faster.

Resist the urge to optimize too early! You will thank yourself later. [2]

Use println! and dbg! for debugging

I find that printing values is pretty handy while prototyping. It’s one less context switch to make compared to starting a debugger.

Most people use println! for that, but dbg! has a few advantages:

  • It prints the file name and line number where the macro is called. This helps you quickly find the source of the output.
  • It outputs the expression as well as its value.
  • It’s less syntax-heavy than println!; e.g. dbg!(x) vs. println!("{x:?}").

Where dbg! really shines is in recursive functions or when you want to see the intermediate values during an iteration:

fn factorial(n: u32) -> u32 {
    // `dbg!` returns the argument, 
    // so you can use it in the middle of an expression
    if dbg!(n <= 1) {
        dbg!(1)
    } else {
        dbg!(n * factorial(n - 1))
    }
}

dbg!(factorial(4));

The output is nice and tidy:

[src/main.rs:2:8] n <= 1 = false
[src/main.rs:2:8] n <= 1 = false
[src/main.rs:2:8] n <= 1 = false
[src/main.rs:2:8] n <= 1 = true
[src/main.rs:3:9] 1 = 1
[src/main.rs:7:9] n * factorial(n - 1) = 2
[src/main.rs:7:9] n * factorial(n - 1) = 6
[src/main.rs:7:9] n * factorial(n - 1) = 24
[src/main.rs:9:1] factorial(4) = 24

Note that you should not keep the dbg! calls in your final code as they will also be executed in release mode. If you’re interested, here are more details on how to use the dbg! macro.

Design through types

Quite frankly, the type system is one of the main reasons I love Rust. It feels great to express my ideas in types and see them come to life. I would encourage you to heavily lean into the type system during the prototyping phase.

In the beginning, you won’t have a good idea of the types in your system. That’s fine! Start with something and quickly sketch out solutions and gradually add constraints to model the business requirements. Don’t stop until you find a version that feels just right. You know you’ve found a good abstraction when your types “click” with the rest of the code. [3] Try to build up a vocabulary of concepts and own types which describe your system.

Wrestling with Rust’s type system might feel slower at first compared to more dynamic languages, but it often leads to fewer iterations overall. Think of it this way: in a language like Python, each iteration might be quicker since you can skip type definitions, but you’ll likely need more iterations as you discover edge cases and invariants that weren’t immediately obvious. In Rust, the type system forces you to think through these relationships up front. Although each iteration takes longer, you typically need fewer of them to arrive at a robust solution.

This is exactly what we’ll see in the following example.

Say you’re modeling course enrollments in a student system. You might start with something simple:

struct Enrollment {
    student: StudentId,
    course: CourseId,
    is_enrolled: bool,
}

But then requirements come in: some courses are very popular. More students want to enroll than there are spots available, so the school decides to add a waitlist.

Easy, let’s just add another boolean flag!

struct Enrollment {
    student: StudentId,
    course: CourseId,
    is_enrolled: bool,
    is_waitlisted: bool, // 🚩 uh oh
}

The problem is that both boolean flags could be set to true! This design allows invalid states where a student could be both enrolled and waitlisted.

Think for a second how we could leverage Rust’s type system to make this impossible…

Here’s one attempt:

enum EnrollmentStatus {
    Active {
        date: DateTime<Utc>,
    },
    Waitlisted {
        position: u32,
    },
}

struct Enrollment {
    student: StudentId,
    course: CourseId,
    status: EnrollmentStatus,
}

Now we have a clear distinction between an active enrollment and a waitlisted enrollment. What’s better is that we encapsulate the details of each state in the enum variants. We can never have someone on the waitlist without a position in said list.

Just think about how much more complicated this would be in a dynamic language or a language that doesn’t support tagged unions like Rust does.

In summary, iterating on your data model is the crucial part of any prototyping phase. The result of this phase is not the code, but a deeper understanding of the problem domain itself. You can harvest this knowledge to build a more robust and maintainable solution.

It turns out you can model a surprisingly large system in just a few lines of code.

So, never be afraid to play around with types and refactor your code as you go.

The todo! Macro

One of the cornerstones of prototyping is that you don’t have to have all the answers right away. In Rust, I find myself reaching for the todo! macro to express that idea.

I routinely just scaffold out the functions or a module and then fill in the blanks later.

// We don't know yet how to process the data
// but we're pretty certain that we need a function
// that takes a Vec<i32> and returns an i32
fn process_data(data: Vec<i32>) -> i32 {
    todo!()
}

// There exists a function that loads the data and returns a Vec<i32>
// How exactly it does that is not important right now
fn load_data() -> Vec<i32> {
    todo!()
}

fn main() {
    // Given that we have a function to load the data
    let data = load_data();
    // ... and a function to process it
    let result = process_data(data);
    // ... we can print the result
    println!("Result: {}", result);
}

We did not do much here, but we have a clear idea of what the program should do. Now we can go and iterate on the design. For example, should process_data take a reference to the data? Should we create a struct to hold the data and the processing logic? How about using an iterator instead of a vector? Should we introduce a trait to support algorithms for processing the data?

These are all helpful questions that we can answer without having to worry about the details of the implementation. And yet our code is typesafe and compiles, and it is ready for refactoring.

unreachable! for unreachable branches

On a related note, you can use the unreachable! macro to mark branches of your code that should never be reached.

fn main() {
    let age: u8 = 170;
    
    match age {
        0..150 => println!("Normal human age"),
        150.. => unreachable!("Witchcraft!"),
     }
}

This is a great way to document your assumptions about the code. The result is the same as if you had used todo!, but it’s more explicit about the fact that this branch should never be reached:

thread 'main' panicked at src/main.rs:6:18:
internal error: entered unreachable code: Witchcraft!

Note that we added a message to the unreachable! macro to make it clear what the assumption is.

Use assert! for invariants

Another way to document your assumptions is to use the assert! macro. This is especially useful for invariants that should hold true at runtime.

For example, the above code could be rewritten like this:

fn main() {
    let age: u8 = 170;
    
    assert!(age < 150, "This is very unlikely to be a human age");
    
    println!("Normal human age");
}

During prototyping, this can be helpful to catch logic bugs early on without having to write a lot of tests and you can safely carry them over to your production code.

Consider using debug_assert! for expensive invariant checks that should only run in test/debug builds.

Avoid generics

Chances are, you won’t know which parts of your application should be generic in the beginning. Therefore it’s better to be conservative and use concrete types instead of generics until necessary.

So instead of writing this:

fn foo<T>(x: T) -> T {
    // ...
}

Write this:

fn foo(x: i32) -> i32 {
    // ...
}

If you need the same function for a different type, feel free to just copy and paste the function and change the type. This way, you avoid the trap of settling on the wrong kind of abstraction too early. Maybe the two functions only differ by type signature for now, but they might serve a completely different purpose. If the function is not generic from the start, it’s easier to remove the duplication later.

Only introduce generics when you see a clear pattern emerge in multiple places. I personally avoid generics up until the very last moment. I want to feel the “pain” of duplication logic before I abstract it away. In 50% of the cases, I find that the problem is not missing generics, but that there’s a better algorithm or data structure that solves the problem more elegantly.

Also avoid “fancy” generic type signatures:

fn foo<T: AsRef<str>>(x: T) -> String {
    // ...
}

Yes, this allows you to pass in a &str or a String, but at the cost of readability.

Just use an owned type for your first implementation:

fn foo(x: String) -> String {
    // ...
}

Chances are, you won’t need the flexibility after all.

In summary, generics are powerful, but they can make the code harder to read and write. Avoid them until you have a clear idea of what you’re doing.

Avoid Lifetimes

One major blocker for rapid prototyping is Rust’s ownership system. If the compiler constantly reminds you of borrows and lifetimes it can ruin your flow. For example, it’s cumbersome to deal with references when you’re just trying to get something to work.

// First attempt with references - compiler error!
struct Note<'a> {
    title: &'a str,
    content: &'a str,
}

fn create_note() -> Note<'_> {  // ❌ lifetime error
    let title = String::from("Draft");
    let content = String::from("My first note");
    Note {
        title: &title,
        content: &content
    }
}

This code doesn’t compile because the references are not valid outside of the function.

   Compiling playground v0.0.1 (/playground)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:7:26
  |
7 | fn create_note() -> Note<'_> {  // ❌ lifetime error
  |                          ^^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`, or if you will only have owned values
  |

(Playground)

A simple way around that is to avoid lifetimes altogether. They are not necessary in the beginning. Use owned types like String and Vec. Just .clone() wherever you need to pass data around.

// Much simpler with owned types
struct Note {
    title: String,
    content: String,
}

fn create_note() -> Note {  // ✓ just works
    Note {
        title: String::from("Draft"),
        content: String::from("My first note")
    }
}

If you have a type that you need to move between threads (i.e. it needs to be Send), you can use an Arc<Mutex<T>> to get around the borrow checker. If you’re worried about performance, remember that other languages like Python or Java do this implicitly behind your back.

use std::sync::{Arc, Mutex};
use std::thread;

let note = Arc::new(Mutex::new(Note {
    title: String::from("Draft"),
    content: String::from("My first note")
}));

let note_clone = Arc::clone(&note);
thread::spawn(move || {
    let mut note = note_clone.lock().unwrap();
    note.content.push_str(" with additions");
});

(Playground)

If you feel like you have to use Arc<Mutex<T>> too often, there might be a design issue. For example, you might be able to avoid sharing state between threads.

Keep a flat hierarchy

main.rs is your best friend while prototyping.

Stuff your code in there – no need for modules or complex organization yet. This makes it easy to experiment and move things around.

First draft: everything in main.rs

struct Config {
    port: u16,
}
fn load_config() -> Config {
    Config { port: 8080 }
}
struct Server {
    config: Config,
}
impl Server {
    fn new(config: Config) -> Self {
        Server { config }
    }
    fn start(&self) {
        println!("Starting server on port {}", self.config.port);
    }
}
fn main() {
    let config = load_config();
    let server = Server::new(config);
    server.start();
}

Once you have a better feel for your code’s structure, Rust’s mod keyword becomes a handy tool for sketching out potential organization. You can nest modules right in your main file.

Later: experiment with module structure in the same file

mod config {
    pub struct Config {
        pub port: u16,
    }
    pub fn load() -> Config {
        Config { port: 8080 }
    }
}

mod server {
    use crate::config;
    pub struct Server {
        config: config::Config,
    }
    impl Server {
        pub fn new(config: config::Config) -> Self {
            Server { config }
        }
        pub fn start(&self) {
            println!("Starting server on port {}", self.config.port);
        }
    }
}

This inline module structure lets you quickly test different organizational patterns. You can easily move code between scopes with cut and paste, and experiment with different APIs and naming conventions. Once a particular structure feels right, you can move modules into their own files.

The key is to keep things simple until it calls for more complexity. Start flat, then add structure incrementally as your understanding of the problem grows.

See also Matklad’s article on large Rust workspaces.

Start small

Allow yourself to ignore some of the best practices for production code for a while.

It’s possible, but you need to switch off your inner critic who always wants to write perfect code from the beginning. Rust enables you to comfortably defer perfection. You can make the rough edges obvious so that you can sort them out later. Don’t let perfect be the enemy of good.

One of the biggest mistakes I observe is an engineer’s perfectionist instinct to jump on minor details which don’t have a broad enough impact to warrant the effort. It’s better to have a working prototype with a few rough edges than a perfect implementation of a small part of the system.

Remember: you are exploring! Use a coarse brush to paint the landscape first. Try to get into a flow state where you can quickly iterate. Don’t get distracted by the details too early. During this phase, it’s also fine to throw away a lot failed attempts.

There’s some overlap between prototyping and “easy Rust.”

Summary

The beauty of prototyping in Rust is that your “rough drafts” have the same memory safety and performance as polished code. Even when I liberally use unwrap(), stick everything in main.rs, and reach for owned types everywhere, the resulting code is on-par with a Python prototype in reliability, but outperforms it easily. This makes it perfect for experimenting with real-world workloads, even before investing time in proper error handling.

Let’s see how Rust stacks up against Python for prototyping:

Aspect Python Rust
Initial Development Speed ✓ Very quick to write initial code
✓ No compilation step
✓ Dynamic typing speeds up prototyping
✓ File watchers available
⚠️ Slightly slower initial development
✓ Type inference helps
✓ Tools like bacon provide quick feedback
Standard Library ✓ Batteries included
✓ Rich ecosystem
❌ Smaller standard library
✓ Growing ecosystem of high-quality crates
Transition to Production ❌ Need extensive testing to catch type errors
❌ Bad performance might require extra work or rewrite in another language
✓ Minimal changes needed beyond error handling
✓ Already has good performance
✓ Memory safety guaranteed
Maintenance ❌ Type errors surface during runtime
❌ Refactoring is risky
✓ Compiler catches most issues
✓ Safe refactoring with type system
Code Evolution ❌ Hard to maintain large codebases
❌ Type issues compound
✓ Compiler guides improvements
✓ Types help manage complexity

Quite frankly, Rust makes for an excellent prototyping language if you embrace its strengths. Yes, the type system will make you think harder about your design up front - but that’s actually a good thing! Each iteration might take a bit longer than in Python or JavaScript, but you’ll typically need fewer iterations from prototype to production.

I’ve found that my prototypes in other languages often hit a wall where I need to switch to something more robust. With Rust, I can start simple and gradually turn that proof-of-concept into production code, all while staying in the same language and ecosystem.

If you have any more tips or tricks for prototyping in Rust, get in touch and I’ll add them to the list!


  1. More experienced Rust developers might find themselves reaching for an impl IntoIterator<Item=T> where &[T]/Vec<T> would do. Keep it simple!

  2. In the talk, I show an example where early over-optimization led to the wrong abstraction and made the code slower. The actual bottleneck was elsewhere and hard to uncover without profiling.

  3. I usually know when I found a good abstraction once I can use all of Rust’s features like expression-oriented programming and pattern matching together with my own types.

Brave

2025-01-09 08:00:00

Web browsers today face increasing demands for both performance and privacy. At Brave, they’re tackling both challenges head-on with their Rust-based ad-blocking engine. This isn’t just about blocking ads – it’s about doing so with minimal performance impact while maintaining compatibility with existing filter lists and adapting to evolving web technologies.

Holiday Episode

2024-12-26 08:00:00

While we try not to get too sentimental, celebrating one year of ‘Rust in Production’ alongside the holiday season feels like a perfect occasion to reflect. For this special episode of the podcast, we’ve gathered heartfelt messages from our guests to the Rust community.

There are two common themes that run through these messages:

  • The importance of writing simple, approachable Rust code to help flatten the learning curve for newcomers
  • Their gratitude for the vibrant ecosystem and the wealth of available crates

As we look ahead to the Rust 2024 edition, we’re excited about what’s to come. Thank you for being part of this journey with us, and here’s to a great start to 2025! May the new year bring us all faster compile times, gentler learning curves, and, if we get lucky, let-chains on stable Rust.

Migrating from TypeScript to Rust

2024-12-13 08:00:00

A Practical Guide for Decision Makers

I wrote this guide for technical leaders and developers considering a move from TypeScript to Rust. After years of helping teams make this transition, I’ll share what works, what doesn’t, and how to make your migration successful.

TypeScript excels at making JavaScript more maintainable, but teams often hit scaling challenges as their codebases grow. You might be facing performance bottlenecks, reliability issues, or maintenance overhead. Rust offers compelling solutions to these problems, but migration needs careful planning and execution. Let me show you how to evaluate if Rust is right for your team, plan a successful migration, and keep your team productive during the transition.

In this article, you’ll learn:

  • How to evaluate if Rust is right for your team
  • Practical strategies for TypeScript-to-Rust migration
  • Common pitfalls and how to avoid them
  • Ways to maintain productivity during transition

Key Differences Between TypeScript and Rust

Aspect TypeScript Rust
1.0 Release 2014 2015
Packages 3 million+ (npm) 160,000+ (crates.io)
Type System Optional Mandatory
Tooling Rich ecosystem, frequent updates Stable, integrated toolchain
Memory Management Garbage collected Ownership system, compile-time checks
Speed Moderate Fast
Error Handling Exceptions Explicit handling with Result
Learning Curve Moderate Steep

Why Teams Consider Rust

TypeScript is a great language, but many teams hit a wall around the 10k to 100k lines of code mark. At this scale, the codebase becomes hard to maintain and teams start to feel the pain.

The problems are clear and specific. While TypeScript has a type system, it remains a dynamically typed language built on JavaScript. Types are optional and can be bypassed using any or unknown or by freely casting between types. Without enough discipline, this leads to runtime errors and bugs.

Memory leaks and security vulnerabilities become more common, especially in large, long-running backend services. Some companies I’ve worked with needed regular service restarts to manage memory leaks.

External packages often have security vulnerabilities and need regular updates. Breaking changes are common, and packages frequently become unmaintained. Frameworks like Next.js introduce frequent breaking changes, forcing teams to spend time on updates instead of business logic.

Performance isn’t guaranteed. TypeScript is fast enough for most cases, but performance-critical applications will hit limitations. Error handling through exceptions and promises can be hard to reason about. Large TypeScript codebases become difficult to refactor and maintain, even with type safety.

TypeScript as a Bridge to Rust

TypeScript’s type system creates an excellent foundation for Rust adoption. Your team already understands static typing and values its benefits. This gives you a head start with Rust, which takes these concepts further and adds more powerful guarantees.

Understanding Rust’s Learning Curve

Rust enforces stronger guarantees than TypeScript through its ownership system and borrow checker. You’ll need to plan for an adjustment period. Most developers need 2-4 months to become comfortable with Rust’s ownership model. They’ll go through a phase of “fighting the borrow checker” – this is normal and temporary. Your job is to keep the team motivated during this learning curve. I’ve seen time and again that developers who push through this phase become the strongest Rust advocates. These developers then become valuable mentors for their teammates.

Rust Has Its Roots In Systems Programming

Rust pushes your team to understand systems concepts better than TypeScript ever required. You need to know the difference between stack and heap allocation. You’ll work with different string types like String and &str. And you should be willing to learn what a pointer or a mutex is.

These concepts might seem intimidating at first, but they make Rust fast. Rust won’t hide these details from you. The idea is that explicit is better than implicit.

Your team will write more efficient code because Rust makes these low-level details explicit and manageable.

You don’t need to be a systems programmer to use Rust and yet, you will need to learn these concepts to become proficient in Rust.

Safety and Reliability

The strict Rust compiler is your strongest ally. You can refactor without fear because the compiler catches mistakes early and consistently. You won’t deal with null or undefined errors. Error handling becomes explicit and predictable with Result.

Ecosystem Maturity

NPM gives you more packages, but Rust’s ecosystem prioritizes quality. Libraries maintain strong backward compatibility. Breaking changes are rare. Rust itself releases new editions every three years with opt-in changes.

Many Rust crates stay in 0.x versions longer than you might expect. Don’t let this worry you – Rust’s type system ensures robust functionality even before reaching 1.0. The ecosystem grows fast, and the existing libraries work reliably. For specific use cases, writing your own library is common and well-supported.

Rust vs TypeScript For Backend Services

TypeScript is a great language for backend services. A lot of companies use it successfully. There are many frameworks to choose from, like Express, NestJS, or Fastify. These frameworks are mature and well-documented.

By comparison, Rust’s backend ecosystem is smaller. You have Axum, Actix, and Rocket, among others. These frameworks are fast and reliable, but they don’t provide a “batteries-included” experience like Express.

That said, Rust’s ecosystem is growing fast and most companies find the available libraries sufficient. I personally recommend axum as it has the largest momentum and is backed by the Tokio team.

Deployment is straightforward. You can build your Rust service into a single binary and deploy it to a container or a server. Rust binaries are small and have no runtime dependencies. This makes deployment easy and reliable.

Rust is Really Fast

Teams are often shocks that Rust is so fast. They go in expecting Rust to be fast, but the reality still surprises them. You can expect an order of magnitude better CPU and memory usage if you’re coming from JS/TS. The effects of that are very real: reduced cloud costs, less hardware, and faster response times.

Most importantly, your runtime behavior becomes predictable. Production incidents decrease. Your operations team will thank you for the reduced overhead.

Planning Your Migration

Write down why you want to migrate before you start:

  • What problems do you face today?
  • Why will Rust solve these problems?
  • Could you fix them in TypeScript instead?

This clarity helps when things get tough.

I know this evaluation isn’t easy. We often struggle to see our codebase’s problems clearly. Politics and inertia hold us back. Sometimes you need an outside perspective. I can help you evaluate your situation objectively and create a solid migration plan. This might sound expensive, but think about your team’s salaries and the cost of making the wrong decision. Good consulting pays for itself quickly. Reach out for a free consultation.

Integration Strategies

You have two main ways to integrate Rust with TypeScript.

WebAssembly

You can use WebAssembly (WASM) to compile your Rust code to a library and call it directly from TypeScript. This works great for speeding up performance-critical components. Teams often start here and expand their Rust usage as they see the benefits.

Standalone Web-Service

Alternatively, you can deploy Rust as separate services. This fits well with microservice architectures. Your TypeScript and Rust components communicate over the network. This gives you a clean separation and lets you migrate gradually.

Find A Rust Champion

You need a Rust champion in your team. This person should have some prior Rust experience and be excited about the language.

Outside help can get you started, but keep the knowledge in-house. You know your codebase and business domain best. A consultant helps with the tricky Rust parts, team augmentation, and training, but your team maintains and extends the codebase in the long run.

They need to believe in the mission.

In order to succeed, your Rust champion needs to be able to motivate the team, answer questions, and guide the team through the learning curve. They work hand-in-hand with the consultant to ensure the team’s success.

Starting Your Journey

Don’t rewrite everything at once! Start small. Maybe pick a monitoring service or CLI tool – something important but not critical. Perhaps you’ll give it a shot during a hackathon or a sprint. Build confidence through early wins.

Ready to make the switch to Rust?

I help teams make successful transitions from TypeScript to Rust. Whether you need training, architecture guidance, or migration planning, let’s talk about your needs.

Migrating from Python to Rust

2024-12-13 08:00:00

A Practical Guide for Decision Makers

This guide is written for technical leaders and developers considering moving their teams from Python to Rust. I used to be a Python developer myself, and I know how daunting it can be to switch to a new language. Base on years of experience helping teams make this transition, I’ll share practical insights on what works, what doesn’t, and how to ensure a successful migration.

Python is an incredibly versatile language that powers everything from web applications to data science pipelines. However, as organizations scale, they often encounter limitations around performance, type safety, and robustness. While Rust isn’t a direct replacement for Python, it has some answers to these challenges.

But is Rust the right choice for your team?

In this article, you’ll learn:

  • How to evaluate whether Rust is the right choice for your Python codebase
  • Practical strategies for Python-to-Rust migration
  • Common pitfalls and how to avoid them
  • Ways to maintain productivity during the transition
  • How to leverage Python’s strengths alongside Rust

Get Your Customized Migration Plan

I help teams migrate from Python to Rust, providing tailored guidance and training. If you’re considering a migration, answer a few questions about your project, and I’ll reach out with a customized plan.

Thank you for your interest!

I'll send you a customized migration strategy based on your responses.

In the meantime, feel free to check out my other articles about Rust.

Key Differences Between Python and Rust

Aspect Python Rust
Type System Dynamic, optional type hints Static, strong type system
Memory Management Garbage collected No GC, ownership and borrowing
Performance Moderate High performance, low-level control
Deployment Runtime required Single binary, minimal runtime
Package Manager Multiple (pip, conda, uv) cargo (built-in, consistent)
Error Handling Exceptions Result type
Concurrency Limited by GIL zero-cost abstractions, no GIL
Learning Curve Gentle Steep
Ecosystem Size Vast (500,000+ packages) Growing (160,000+ crates)

Why Python Teams Consider Rust

Python developers often want to transition to Rust for several reasons:

  1. Developers interested in Rust are likely willing to understand systems programming concepts. They grew out of Python’s limitations and are looking for more control over performance and memory management.

  2. Python developers often long for stronger type guarantees. They appreciate Rust’s static type system and the safety it provides.

  3. Developers with a Python background often work on data processing or web applications. These are areas where Rust’s performance benefits are most pronounced.

While Python excels at readability and rapid development, teams often hit scaling challenges as their applications grow. Below, I’ve listed a few common pain points that get mentioned in consultations with teams considering a migration.

Performance Bottlenecks

Python’s Global Interpreter Lock (GIL) limits true parallelism, making it challenging to fully utilize modern multi-core processors. There is a version of Python without the GIL, but it doesn’t solve the performance issues.

While tools like asyncio help with I/O-bound tasks, CPU-intensive operations remain constrained. Teams often resort to complex workarounds involving multiple processes or C extensions.

Type Safety Concerns

Despite Python’s type hints, runtime type errors still occur. Types are optional in Python. Developers need the discipline to add and maintain type hints consistently, which can be challenging in large codebases. Furthermore, adoption is inconsistent across the Python ecosyste (e.g., third-party libraries). Large Python applications can become difficult to maintain and refactor confidently.

From experience, there is a breaking point around the 10-100k lines of code mark where the lack of type safety becomes a significant liability.[1]

Deployment Complexity

Python applications require managing runtime environments, dependencies, and potential version conflicts. A lot of the issues can be mitigated with containerization. However, bundling Python applications for deployment is not an easy task, especially when targeting platforms with different architectures.

Resource Usage

Python has a relatively manageable memory profile, but it can be inefficient for certain workloads. For example, Python’s memory overhead can be significant for large-scale data processing or long-running services. CPU-bound tasks often get offloaded to C extensions or to worker processes, adding complexity and overhead.

Key Challenges in Transitioning to Rust

The transition from Python to Rust presents unique challenges:

Syntax

Python is very syntax-light, which makes it easy to read and write. By comparison, Rust is full of “symbols” and keywords that can be intimidating at first.

Developers need to see through the syntax and understand the underlying semantics to become productive. This is a critical step in the transition process.

Here is an example of a simple list comprehension in Python:

# A list of bands
bands = [
    "Metallica",
    "Iron Maiden",
    "AC/DC",
    "Judas Priest",
    "Megadeth"
]

# A list comprehension to filter for bands that start with "M"
m_bands = [band for band in bands if band.startswith("M")]

# A list comprehension to uppercase the bands
uppercased = [band.upper() for band in m_bands]

# We get ["METALLICA", "MEGADETH"] 

Rust does not have a direct equivalent to list comprehensions. Instead, iterator patterns are used to achieve the same result:

let bands = vec![
    "Metallica",
    "Iron Maiden",
    "AC/DC",
    "Judas Priest",
    "Megadeth",
];

let uppercased: Vec<_> = bands.iter()
                    .filter(|band| band.starts_with("M"))
                    .map(|band| band.to_uppercase())
                    .collect();

// uppercased = vec!["METALLICA", "MEGADETH"]

Ownership and Borrowing

Python developers need to adjust to start “thinking in Rust” to avoid common pitfalls. Plan for a 3-4 month learning period where developers will need to understand concepts like:

  • Stack vs heap
  • Borrowing and move semantics
  • Trait-based composition (instead of Python’s OOP model)

These are fundamental concepts in Rust and it’s important to get them right to become really effective.

Lifetimes are another concept that can be challenging to grasp initially, but you can get a long way without fully understanding them. Don’t worry about lifetimes when you’re just starting out.

Pointer handling and boxing is another area where Python developers need to adjust. However, beginners can often get by without understanding this in detail.

Type System Adaptation

Moving from Python’s dynamic typing to Rust’s static typing requires another mindset shift. Developers suddenly need to:

  • Think about types upfront
  • Understand generics and traits
  • Lean into Option<T> and Result<T, E> for error handling
  • Use enums for modeling complex states

This can feel intimidating at first, but it’s actually a lot of fun once you get the hang of it. The compiler is helpfully guiding you along the way, and you can start without a deep understanding of all the concepts.

The pattern matching syntax in Rust can be a game-changer for developers coming from Python and is often cited as one of the most enjoyable features of Rust.

Integration Strategies

There are several ways to integrate Rust into your Python codebase:

1. PyO3 (Python-Rust Bindings)

PyO3 lets you write Python extensions in Rust or call Rust functions from Python.

This is ideal for:

  • Optimizing performance-critical components
  • Gradually introducing Rust while maintaining Python interfaces
  • Creating Python packages with Rust internals

2. Microservices

For distributed systems, you can:

  • Build new services in Rust
  • Migrate existing services one at a time
  • Use REST or gRPC for inter-service communication

If you already have a microservices architecture, this can be a great way to start. You can build new services in Rust and gradually replace old Python services as needed. The new services can be deployed alongside the old ones, and you can ensure that the APIs are compatible.

3. CLI Tools and Utilities

If you’re just staring out, I recommend to write a command-line tool, which is an excellent candidate for getting your feet wet.

They have all the positive indicators for a successful first project:

  • They are self-contained, so you don’t have to worry about integrating with the rest of the codebase
  • The deployment is simple, as you can just ship a single binary
  • Rust shines in this area, as it’s very convenient to write command-line tools in Rust
  • There is no “startup cost” when a CLI tool runs with Rust (as opposed to Python where the interpreter needs to start up)

4. Worker Processes

Let’s say you have a web application that needs to do some heavy lifting. One common way is to offload the work to a worker queue. That’s a great place to test out Rust, as you can directly compare the performance and developer experience with Python.

You can use a message queue like RabbitMQ or Kafka to communicate between the Python web application and the Rust worker.

Data Processing Pipelines

Are you using Python for data science or ETL tasks? For example, you might be using Pandas or Dask for data processing.

Rust has some excellent libraries for data processing, like Polars and Apache Arrow. A lot of teams start by moving the data preprocessing and ETL tasks to Rust, and they like it so much that they move more and more of the business logic over.

That worked extremely well for a few clients I worked with, as they could leverage Rust’s performance and reliability for the most critical parts of their data processing pipeline. After a short learning period, the team was as productive in Rust as they were in Python.

Planning Your Migration

A successful migration requires careful planning:

  1. Start Small

    • Choose non-critical components first
    • Focus on areas where Rust’s benefits are most valuable
    • Build team confidence through early wins
  2. Invest in Training

    • Allocate time for learning Rust fundamentals
    • Consider bringing in external expertise for guidance
    • Set realistic expectations for the learning curve
  3. Measure Success

    • Define clear metrics (performance, resource usage, development velocity)
    • Document improvements and challenges
    • Adjust strategy based on results

Common Pitfalls to Avoid

Rust’s ecosystem is smaller than Python’s.

On top of that, Rust has a much smaller standard library compared to Python. This means you’ll commonly rely on third-party crates for functionality that’s built into Python.

Depending on your use case, here are some key differences to consider:

Data Science

Python dominates data science with libraries like NumPy and Pandas. However, Rust is making inroads with libraries like Polars and Apache’s Arrow.

You don’t have to migrate your entire data science stack to Rust. Chances are, you have experts on your team who are comfortable with doing data analysis in Python. You can start by using the Rust libraries for data preprocessing and ETL tasks. They have Python bindings, so you get a lot of the benefits of Rust without having to do a full migration.

Backend Services

Rust wasn’t initially planned to be a strong contender in the web development space. This has changed in recent years with the rise of frameworks like Axum and Loco. Now, Rust is a viable option for building high-performance APIs and web applications. It is one key are the Rust team is investing in, and the ecosystem is maturing rapidly.

In combination with sqlx for database access and serde for serialization, Rust is a very effective choice for web backends. What surprises many Python developers is how similar it is to working with other web frameworks like Flask or FastAPI. Another surprise is how robust the final product is, as it catches many bugs at compile time and scales extremely well. Production Rust web applications are extremely robust. This lifts a lot of the burden from the operations team. I expect more backend services to be written in Rust in the future – especially for high-performance applications.

Keeping Python’s Strengths

Not everything needs to be migrated! Python excels at:

  • I personally prototype in Rust, but Python is still a fine choice for prototyping.
  • Data analysis and visualization are great in Python (e.g., Pandas, Matplotlib)
  • Machine learning workflows (e.g., TensorFlow, PyTorch)
  • Admin interfaces and tools (e.g., Django admin)

Consider a hybrid approach where each language handles what it does best.

One winning strategy is to explore a space in Python and moving production-workloads to Rust once the requirements are clear and the project can benefit from performance and scale. Speaking of which…

Expected Big Performance Improvements

Actual performance numbers vary significantly based on workload and implementation, so take these numbers with a grain of salt.

Based on my experience helping teams migrate, here are typical improvements:

  • CPU Usage: 2-10x reduction in CPU utilization
  • Memory Usage: 30-70% reduction in memory footprint
  • Latency: 50-90% improvement in response times
  • Throughput: 2-5x increase in requests/second

Especially the P90 latency improvements are often surprising to teams. There are very few outliers and things tend to run smoothly, no matter the load. In Python, this is a common source of frustration, where a single request can take significantly longer than the rest. Production payloads are extremely boring in Rust. The DevOps team will thank you.

Conclusion

Migrating to a different programming language is a significant undertaking that requires careful planning and execution. The step from Python to Rust is no exception.

While the learning curve is steep, the benefits in terms of performance, reliability, and maintainability can be substantial.

Success depends on:

  • Realistic timeline expectations
  • Strong team support and training
  • Clear understanding of migration goals
  • A pragmatic approach to choosing what to migrate

Remember that this isn’t an all-or-nothing decision! Many organizations successfully use Python and Rust together, leveraging each language’s strengths. Write down your reasoning for the migration. Many issues can be solved in Python, and the migration might not be necessary. However, if you’re hitting the limits of Python, Rust is a strong replacement.

Ready to Make the Move to Rust?

I help teams make successful transitions from Python to Rust. My clients moved critical workloads to Rust and saw significant improvements in performance and reliability. Whether you need training, architecture guidance, or migration planning, let’s talk about your needs.


  1. See discussions on large-scale Python applications on Reddit and HN.