Why you should use Rust
I work with Kubernetes and other cloud-native technologies every day in my job at MaibornWolff. I not only use existing products, I routinely also implement my own tools. Lately I have mostly used Rust as my programming language. In the past my language of choice was Python, and for many things it still is. I have also used Go. But my clear favorite nowadays is Rust. In this article I want to explain why I like Rust and why I think it is a great language. And why I would use it for Kubernetes development, even though most of the cloud-native ecosystem is written in Go.
What are we talking about
In case you are not familiar with these languages, I want to quickly give an introduction:
Rust is a statically-typed compiled language originally invented at Mozilla (the makers of the Firefox browser), but is now developed independently under the stewardship of the Rust Foundation. Rust is often hailed as a systems programming language which can be used to replace low-level C code. It works well there and supports many embedded devices and microcontrollers, like the ESP32 family or the Raspberry Pi Pico. But Rust is also used for high-level development with a focus on safety and speed (Mozilla started it to rewrite parts of its browser in a safer language). Rust aims to prevent entire classes of bugs. From null pointers (a pointer to non-existing memory), use-after-free (using memory after it has been cleared) to data races (two pieces of code accessing code at the same time leading to corruption and interference).
Go is also a statically-typed compiled language invented at Google (and still developed heavily there). It is aimed at being easy to use so developers can quickly get started and produce fast results. It is aimed primarily at high-level development (e.g. CLIs or web services) and does not support embedded/constrained devices. For these situations TinyGo exists, but it is limited and does not support the entire Go ecosystem. Go aims to have quick compile times and to make cross-compiling (compile on one system for another architecture, e.g. compile on ARM64 Linux for AMD64 Windows) simple and hassle-free.
Python is a dynamically-typed interpreted language developed in the 90s by Guido van Rossum with a focus on readability (its hallmark is the use of indentations for structuring). It is one of the most widely used programming languages and can be used for almost every use case. From simple scripts to complex applications. Python is very prevalent in the data science and AI spaces as it is easy to learn and simple to use.
I have worked with all three of these languages, but mostly with Python and Rust. The comparison in the following sections is neither scientific, nor do I aim for completeness or objectivity. It is my personal experience and opinion. Other people will have different opinions and views, and theirs are no less valid than mine.
Python is a good language
For years, Python was my language of choice. It is easy to read and quick to learn and write. Its dynamic behavior makes it simple to express ideas and produce working code. It also has a vast ecosystem. If the already mighty standard library ("batteries included") does not have a module for something I want to do, I will almost certainly find an external library to help me (via the Python Package Index).
I have written diverse programs in Python. From simple scripts, to CLIs, to complex business logic microservices, to Kubernetes Operators. I mostly enjoyed and still enjoy writing code in Python, both for private projects and at work.
One of the loudest arguments against Python is its execution speed. Which is logical. As an interpreted language, it will never be as fast as a compiled language. But reality is, in a majority of situations raw computational speed is not your bottleneck. Either because the program is often waiting on I/O (network responses) anyway or is mostly idling and waiting for requests and any activity has no hard latency requirements. Kubernetes Operators are a good example here. They idle most of the time waiting for new events from the Kubernetes API. When an event arrives, if its response takes a few milliseconds longer, nobody will notice. Ease of implementation is a worthwhile trade-off for slower execution speed.
But I have found that the dynamic typing of Python (variables don't have a fixed type) make things harder the more complex a codebase gets. Bugs that a compiler would easily catch are in Python often only found at runtime or through rigorous unit testing. The classic example is calling a function with the wrong number of arguments or with arguments with the wrong type. The dynamic nature of Python, which allows cool and concise implementations, also makes it harder for the IDE to understand what is going on (e.g. finding all locations a specific function is called from).
For me this leads to some uncertainty when making changes in a Python codebase. Let's say I refactor a function. Can I really be sure I caught all call sites and that they all use the correct types my newly refactored function expects?
Static analysis and linter tools like PyLint and ruff can help there. Type hints also bring some parts of static typing to Python. But it is an add-on that is not enforced and not as permeating as it would be in a compiled statically-typed language.
Another negative aspect of Python is the Global Interpreter Lock (GIL) that prevents true multi-threading, as only one thread can run at a time. Libraries like multiprocessing have been developed to aid there, but still make it harder than necessary. With free-threading Python is currently developing a solution without the GIL, but it is still experimental.
Taking all this into account, if a new project will likely become a big codebase or have performance or parallelism requirements, I will choose a compiled language like Rust.
Go is simple but has flaws
Go has evolved as the language of choice for the cloud-native / Kubernetes ecosystem. Many important projects like Prometheus are written in Go, and Kubernetes itself. This naturally means that the libraries and tooling available for developers working with Kubernetes in Go are very mature and feature-rich.
As the language is widely used, it also means there is a vast ecosystem of libraries and tutorials. Getting started can actually be very easy and simple. I have found it works better if you are not that experienced in other languages or don't naturally gravitate to advanced features of them. Go doesn't have them, so trying to use them only slows you down. Examples are inheritance and generics (although Go has finally gotten a first implementation for the latter, if still a bit basic).
But after working with Rust and Go both for quite some time, I have found some oddities of the Go programming language that at least for me makes it an inferior tool to wield.
Go still has a concept of null pointers (or rather nil as its called there). Inherited from C and C++ it means a variable for a memory address pointing to nothing. Accessing such a pointer crashes the program. Which means a programmer always needs to check if a pointer is nil before using it. But this is not enforced by the language or the compiler.
In contrast to languages like Python or Java, Go does error handling via return values instead of exceptions (there are many discussions about the merits of both approaches that I don't want to get into). The standard pattern in Go looks like this:
result, err := some_function_call();
if err != nil {
// handle error
}
// then do something with the result
But I am not required to actually check for or handle the error. I could just as easily write:
result, _ := some_function_call();
// do something with result
Which is dangerous, as result could contain an invalid value and corrupt anything I do from that point on.
Similar to this is the fact Go silently initializes variables with default values, leading to unintended behavior. As an example, let's define and use a struct:
type Foobar struct {
name string
}
func main() {
var foo = Foobar{name: "hello"};
fmt.Printf("Foobar: name: %s", foo.name);
}
If I later extend the Foobar struct with an additional field (id int32), I can still write var foo = Foobar{name: "hello"} and Go will silently initialize id to 0. The compiler will not warn me, so if I intended to always set an explicit value for id, good luck with that. The compiler will not help me find such occurrences.
Another thing I don't like about Go is duck-typing. In Go I can define interfaces (a set of methods) that can be implemented for structs. In most other languages I must explicitly state that I am implementing an interface for a type and the compiler will complain if I forget a method or the defined signatures don't match. Go instead uses duck-typing: If a struct has all the methods implemented in the exact way defined by an interface, that struct then implicitly implements the interface. But if I accidentally mess up one of the methods, the compiler will not tell me. There are of course tricks like forcing a conversion of an instance of the struct to that interface to get a compiler error, but this must be done specifically by the developer.
These are just some of the problems I have with Go. If you want to read some very long but interesting rants about Go, I recommend the articles from fasterthanli.me on the topic.
All in all I feel that Go has many design problems that make working with a large or complex codebase unnecessarily hard. When chasing bugs or wanting to make sure I produce correct code, I have found Rust and its compiler much more enjoyable than Go.
Why rust is great
Now let us look at Rust. First off I will admit that Rust has a steeper learning curve than other languages. Mostly due to the borrow checker. It enforces that a variable (or rather memory location) cannot be read from and written to at the same time. This is a good things because it prevents whole classes of bugs (like null pointers, use-after-free, data races). But it also makes expressing ideas in code harder because it is not always easy to write the code in a way that the borrow checker is happy. Even if our experience tells us the code should be sound, it can be a hassle to convince the borrow checker of that.
But once you get past the borrow checker Rust has a host of features, that in my experience make developing with it a nicer experience than with many other languages.
First off is what I call fearless refactoring. Due to the compiler being very strict and undefined behavior being impossible, it will catch many problems when doing a refactoring. The example from Go above with the extra struct field that is silently initialized would not be possible in Rust. The compiler would complain loudly. Or arguments or types changing in a Python function. Again the compiler will detect such problems and complain instead of silently converting values into different types.
The complaints of the compiler itself are another great thing: Its error messages are almost always clear and easy to understand and often even provide suggestions on how to fix the problem. If you have ever tried to read a compiler error from a templated C++ function, it will feel like night and day.
Error handling in Rust is top-notch. Rust has sum types (called enums) and uses them extensively. If a function can return an error, it should return the Result enum. This enum has values for the good case (Ok) and the error case (Err). To access the returned value I must explicitly check the result for the error case and handle it (even if that handling means just panicking and ending the program). Silently ignoring the error like in Go is just not possible.
The same concept Rust also uses for nullable values. Instead of having a null pointer or an implicit null value, it has the Option enum (with values Some and None). This makes it explicit if I have a value or nothing and forces me to check instead of blindly following a null pointer. It also introduces a clear differentiation between value and error. In C for example it is common to return negative numbers to signal errors (e.g. a function returns the number of bytes read, but uses -1 to signal an error).
With the question mark (?) operator Rust makes it easy to avoid writing the error checking cascades of Go. For each instance the compiler will generate code that unpacks the result if it is Ok or returns with the error. It makes code much more readable. An example:
fn do_something() -> Result<u32, SomeError> {
let a = one_call()?;
let b = second_call()?;
a + b
}
There are many more great aspects of Rust. Among them well thought-out APIs and compile-time optimizations. In my experience, and this matches with what is expressed in forums and on social media, the Rust compiler checks for and finds a lot more problems than other languages. If the compiler doesn't complain, there is an excellent chance the code will do what it is supposed to do ("if it compiles, it runs"). Of course the compiler will most likely not catch logic bugs. But even this increase in confidence is a huge win for developers.
The myth of the borrow checker
There are a lot of arguments floating around why Rust is not a good choice as a programming language. I want to look at two of the (in my experience) most common ones.
The first is that the borrow checker makes life harder. For context, earlier I mentioned that Rust ensures memory cannot (in simplified terms) be written and read at the same time. For this Rust has the concept of ownership. There is always exactly one owner of a variable. The owner can borrow the variable to one or more other locations (e.g. functions). There can be many immutable borrows (read-only) but only one mutable (variable can be written to) and only if there is not an immutable borrow at the same time. This concept prevents concurrent access, data races and other classes of bugs. The validation is done by the borrow checker of the compiler. It also allows Rust to not need a garbage collector. As all the tracking who has access to a variable (or more accurate: memory location) is done at compile time, the compiler knows exactly when memory is no longer accessed and can insert instructions to free it. Other languages don't have that concept. In C, Go and others you can hand out pointers without limitations and either deal with the clean up manually (C/C++) or let the garbage collector take care of it (Go).
When programming in Rust, you will have fights with the borrow checker, where it complains it cannot validate that the rules about borrows (mutable and immutable) are met, which wouldn't happen in other languages. Many see this as a disadvantage of Rust.
I only partially agree. Yes, you have to fight with the borrow checker, but in my experience it is not very often. At least if you have some experience with Rust and don't blindly try to apply concepts from Java or Go. If the borrow checker complains, it often times either points at a real problem, that in another language might only have been discovered at runtime. Or it is a hint that the code you are writing is not ergonomic Rust and you should rethink it. There are situations where satisfying the borrow checker is just not possible. The most often cited one is double-linked lists. For this and other situations Rust has concepts to move checking to runtime (for example reference counting structures). But again, in my experience, unless you are playing heavily with complex data structures, the borrow checker does not pop up often, and if, then it is to help you.
Waiting for the compiler
The second argument why Rust is not a good choice is the speed of the compiler.
In comparison to many other languages, especially Go, the Rust compiler can be quite slow. This can be blamed for a good portion on the fact that the Rust compiler does more checking (like the borrow checker) which takes time.
I have several counters to this argument:
- Compiler speed is constantly being worked on and is improving all the time. If you read comparisons from several years ago, they will no longer be accurate.
- It is better to find bugs at compile time than at runtime and I consider a slower compile a good trade-off for that.
- Rust has a fast debug build which a developer can use to get a local build quickly, and a slower release build which takes longer but produces faster code and smaller binaries. Most of the time the slow release build is not a real problem for developers in their day-to-day work.
- The Rust compiler is quite good at incremental compilation, so only a complete fresh build will really be slow. In my experience, an incremental build with only small code changes will be as fast or even faster than a Go build.
I still agree that Rust compile times could be better, but with a good workflow they are not a big hassle, especially if you consider the benefits of more compile-time validation.
My workflow looks like this:
- During development I run only debug builds to get fast feedback.
- Bigger projects I split into several crates (the Rust equivalent of libraries/modules) to better utilize parallel and incremental compilation.
- Release builds I mostly run in CI and with a caching solution like sccache in place.
So in the end, yes, the Rust compiler is slower, but in my experience and opinion it is not the big problem that others make it out to be.
Rust for cloud-native
Earlier I have talked about the Kubernetes ecosystem and that it is very much built using Go. I have written Kubernetes Operators in both Go and Rust. And while I agree that the zoo of libraries available for Go is more expansive and mature than the ecosystem for Rust, I still think Rust is a good option to write Kubernetes tools in.
The biggest argument there is the kube-rs project and libraries. They implement not just a Kubernetes client library, but also support tooling and wrappers to implement Kubernetes controllers and operators.
This includes
- The ability to generate a Custom Resource Definition from Rust structs
- An automatically updated in-memory store of Kubernetes objects
- Utilities for managing the Kubernetes reconcile loop, including finalizer support
Apart from Kubernetes-specific libraries, the general networking and data format support in Rust is great. Just look at Serde, a framework for serializing and deserializing data structures to and from a variety of formats (like JSON or YAML). I often rely heavily on its maturity, flexibility, and feature richness.
In my experience, the design of the Rust language and its compiler make it easier to write correct code and detect problems early. I had many instances where the Go compiler would happily accept code that later lead to null pointer panics, impossible type casts and other problems that the Rust compiler would have screamed at me for during compile time.
I would always take the (in my opinion only a little) less mature ecosystem, if it means I get a powerful language and great compiler support.
When is Rust not a good choice
I have now written many lines on how good Rust is. But of course Rust is not the unequivocal savior that should be used everywhere. Rust is a great language, but it is not perfect and is not the best choice for every situation.
So when is Rust not a good choice in my experience?
- If you do rapid prototyping, meaning you want to quickly validate an assumption or a concept and iterate on it. Then Rust will often slow you down and you are better off with something like Python or JavaScript.
- If you and your team have no prior experience with Rust, using it for anything major will not end well. It will likely lead to frustration and slower progress than with languages the team already knows. If you don't have a specific need that only Rust can fill, you are better off with another language. By all means start learning Rust with experimental side projects or smaller services, but only jump in head-first when you have the experience.
- You need to integrate with an existing Go codebase or libraries. Go is not a language designed to be easily used in conjunction with other languages (Foreign Function Interface, FFI). Unless the integration interface is a network barrier and protocol (gRPC, REST), you will be better off staying with Go.
Rust is a good idea
As you have read the article till here, you have seen I am a big fan of Rust. I really like the language and try to use it whenever possible. From my experience, there are several areas Rust can be a particularly good choice:
- If you need performance: Because it has no garbage collector and great compile-time optimizations, Rust programs are often faster and have more predictable performance characteristics than programs in Go or similar languages. And they are mostly as fast as C or C++ programs.
- If you need to write a systems level service: Rust is used in the Linux kernel and entire operating systems are written in Rust. It has clearly proven its worth in these areas.
- If you need to integrate with other languages: Rust has mature integrations with other programming languages, like C++ or Python, which make Rust a good fit if you for example need to interface with an existing C++ library. Or you can enhance your Python service by reimplementing performance-critical parts in Rust to get the best of both worlds. I have found the Rust integration tooling for both languages mature and feature-rich.
- If you want to really trust your compiler: As I've explained, the Rust compiler catches more errors than most others. It makes programs better and gives developers more confidence in their code, which can lead to significantly cheaper development as bugs are caught earlier in the process.
If you want a feature-rich language that allows you to express complex ideas and still trust in their correctness and readability, then Rust is the language for you.
Don't be afraid of Rust. If you have never used the language, I strongly encourage you to try it out. There is great documentation and a vibrant community to help. If you already know Rust, push to use it more in your company. It is a great choice with many benefits.