In recent years, the shift away from dynamic, high-level programming languages back towards statically typed languages with low-level operating system access has been gaining momentum as engineers seek to more effectively solve problems with scaling and reliability. Demands on our infrastructure and devices are increasing every day and downtime seems to lurk around every corner. Dynamic languages that abstract away things like type safety and memory management can present significant challenges when it comes to stability and efficiency, especially for large teams aiming for world-class performance.
Static typing has seen a dramatic resurgence with traditionally dynamic languages like JavaScript, PHP, and Python adding more type checking features to help tame the chaos of runtime behavior. The latest State of JS survey shows that far more professional developers are using TypeScript than not, with the lack of native typing being cited as the most painful thing about the language by far. A rich type system can be a very effective ally for engineers that want to craft high-quality code.
Not all high-performance statically-typed solutions are created equal, however. C/C++ is the traditional choice for high-performance low-level programming and has enjoyed a dominant position in this space for decades now, but the experience while developing and debugging can be quite challenging. However, growing concerns around security exploits taking advantage of its lack of memory safety and concerns about reliability in the wake of incidents like the CrowdStrike incident have prompted a growing number of engineers to find alternatives with more safety and better quality-of-life features.
Go and Rust are two of the most prominent beneficiaries of this movement, and they both provide good memory safety and excellent performance while making it easier for developers to work with them day to day.
Go and Rust: High-Performance Alternatives
Go has been around for a bit longer, gaining popularity in the early 2010’s as prominent tools based on Go emerged - like Docker, Kubernetes, and Terraform. It has an easier learning curve due to its intentionally simplistic type system, and a well-designed channel-based concurrency system using green threads. Performance is often better than alternatives like Python or JavaScript with a lower memory and resource footprint than Java or .NET. It can be a difficult language to master, however, with some rather unpredictable and non-obvious quirks that often require deep experience to understand.
Rust competes in the same space with an innovative memory ownership and lifetime tracking system that enables outstanding performance with an even more minimal memory and resource footprint, while avoiding garbage collection and providing a more strict and feature-rich type system with support for robust abstractions. Its concurrency system uses hardware threads, and provides strong protection against the common pitfalls of shared memory between threads. It’s generally more difficult to learn and has a more complex syntax, but includes much stronger guardrails to prevent developers from shooting themselves in the foot.
Go is supported by an extensive runtime library that handles things like garbage collection, concurrency management, type reflection, and more. Rust targets zero-cost abstractions wherever possible, with a minimal runtime library that is much smaller and involves less overhead. Both languages compile down to binary executables, and they are both well-suited for both container-based and serverless environments.
With this article I intend to share my experience getting up to speed with Go and Rust and using them over the course of a few years. These are my personal perspectives and opinions after spending time with each and engaging with their communities, and your mileage may vary.
Onboarding
Onboarding refers to the time and effort to get new team members up to speed and productive if they’re not familiar with the language already. This section covers learning how the language works well enough to begin building architecture and features as a part of a team.
Go
The theme with Go is “easy to learn, difficult to master”. You can get up and running and write code fairly quickly, but many of the common conventions will be unfamiliar and some of the behavior around memory allocation, garbage collection, and pointers can be hard to understand or predict. However, runtime performance is very good (though it can sometimes require some tuning) and Go’s async and concurrency systems do a great job of taming the complexity of multi-threading and awaiting I/O.
Goroutines and channels are a big advantage, eliminating the “red vs. blue” function compatibility problem that plagues many modern languages and avoiding the need for syntactic sugar like “async/await” to make them usable. Goroutines are treated similarly to “immediately invoked function expressions” (IIFEs) in JavaScript and data is passed to other threads via channels, avoiding the perils of shared memory.
One of the big downsides is the loose management of pointers (memory address references), which can lead to null-pointer exceptions and accidental mutation in ways that aren’t always obvious or easy to prevent. Some types are implicitly reference types behind the scenes and there’s no easy way to make data truly immutable, so it’s not always apparent when multiple functions have access to the same memory pointers and might step on each other’s toes.
The lack of inheritance will throw some newcomers for a loop, but the tools for composition that Go provides are a good replacement and prevent the kind of “ravioli code” messes that make multiple levels of inheritance difficult for many to work with.
Rust
Rust features a more advanced type system that comes with a complex syntax, so it takes additional time up-front to learn how to work with it and understand the feedback presented by the compiler. To avoid garbage collection and guarantee you won’t run into null-pointer exceptions or undefined behavior, Rust’s compiler keeps track of whether a pointer is alive, whether it is mutable or not, and what function currently owns the value. Memory allocation behavior is well-defined and can be accurately predicted ahead of execution, and outstanding performance is typically easy to achieve if you have a good understanding of the rules around lifetimes and mutability.
Data is immutable by default, and any kind of shared ownership of values comes with strict controls to prevent mutation by more than one owner at a time. The type system also keeps track of how portable each value is across multiple threads, bringing confidence in concurrency but presenting more challenges while learning. Lifetime issues with variables held across async/await points can be difficult to understand and track down, because there is a lot of complexity hidden away by the syntactic sugar. These features bring a great amount of control and performance to concurrency, but they are a significant hurdle to overcome in the onboarding process.
Traits and associated types can be tough to learn as well, and the syntax around them can become messy with complex abstractions. The tradeoff is worthwhile, however, as you can go on to build sophisticated library APIs comparable to those in higher-level languages, with sound type safety and very little (if any) runtime cost. The type system allows for great flexibility and confidence in abstractions, making it easier to work with related types and build rich behaviors with your traits.
The time it takes to get up to speed with Rust is a worthwhile trade-off if reliability and performance are high priorities. Its extensive strictness and focus on failing loudly rather than silently helps move day-to-day troubleshooting away from Staging or Production and pushes it up into the develop and build phases. With the focus on zero-cost abstractions and optimal memory layouts, Rust performs very well by default while using minimal system resources and typically doesn’t need extensive performance tuning.
Building
Once your team is able to be productive with Go or Rust, now they need to effectively build and test their solution. This covers the experience of building an application from scratch and managing application architecture, business logic, shared libraries, tools, and testing.
Go
Go is heavily focused on getting out of the developer’s way and letting them do what they want. The type system is intentionally streamlined and minimal, which comes with pros and cons. Until recently the only way to write effective abstractions (with polymorphism) was to use the type reflection system, which relies on a runtime library that comes with some performance overhead and generally sacrifices a lot of type safety. Generic type parameters landed in 2022 and provided a better way to approach the problem of polymorphism, but they come with some distinct limitations like the inability to use them with struct (object or class) methods.
If you’ve put in the hard work to become a Go expert and you understand how it works deep down, however, you can avoid the tricky “footguns” and be very productive with Go. The minimalist design of the language and fast compilation times are attractive features, and they’re complemented by a great standard library and an ecosystem of outstanding open source components and tools. Performance is easy to achieve without extensive tuning and troubleshooting, and the “looseness” of the compiler supports rapid prototyping and prevents too much head-scratching over type errors.
There are frameworks available for many purposes, but there is a healthy community emphasis on interchangeable components so you can easily piece together your own architecture by using libraries together. Code generation is an approach that is commonly used across many tools for things like object-relational mapping, API client interactions, GraphQL schema management, mocking structs for testing, and more. Functional programming is less common in the community, though it is gaining traction now that generic type parameters are supported.
The Go ecosystem provides good tools for effective unit and integration testing, and can be deployed on a wide variety of platforms. It largely compiles down to a single statically-linked binary, but it does dynamically link to the C standard library for some things. Rust shares this single-executable characteristic, with both languages by default including all non-libc dependencies within the binary itself. This simplifies packaging and makes it easy to deploy in serverless or container-based contexts.
Rust
In contrast, Rust is much more focused on preventing the developer from encountering any “footguns." Null-pointer exceptions and “undefined behavior” issues are guaranteed by the compiler not to occur in almost all cases because of how strict its ownership and lifetime tracking is, but you can use “unsafe” escape hatches if truly needed. These exceptions are explicit and easily spotted in a PR, so they are readily apparent during code reviews. The challenge of shared-memory across multiple threads is expertly handled with a type system that understands how thread-safe your data is and enforces the use of proper synchronization techniques to prevent collisions and unexpected behaviors.
The end result is that it takes more up-front time to build a foundation, but then you can iterate and even radically change with much more confidence and velocity. You can write abstractions that encode a great deal of behavior in the types, helping guide others who come along afterwards to extend or enhance and preventing tools from being used in ways that weren’t intended. The downside is that type errors can be quite verbose and complex when it comes to the constraints around detailed abstractions. When building shared tools and libraries, special care needs to be taken around “ergonomics” and making it easy to use abstractions effectively.
Similarly to Go there are some big frameworks available, but the community is more focused on small interchangeable tools that can be pieced together to form a whole. Documentation can sometimes be sparse, but the rich type system helps bridge that gap by communicating more of the creator’s intent in the types themselves. The Rust community strongly prefers macros over code generation, with many libraries taking advantage of macros to streamline the developer experience and provide the same kind of higher-level functionality while preserving strictness.
Flexibility and interoperability are strong advantages. Rust provides tools for deployment to embedded or bare-metal environments, and it allows you to swap between different concurrency models and memory management strategies as needed for particular use cases. Rust’s interop with other language ecosystems is a particular point of pride, with support and tooling available for foreign-function interfaces and embedding in other language environments. Rich compatibility with C is a particular highlight, making it possible to build safe and efficient wrappers around unsafe low-level code. This has allowed Rust to serve as a niche portion of a codebase rather than needing to own the whole thing, increasing adoption in many contexts like desktop and mobile operating systems and non-Rust services that need targeted performance or precision in specific bottleneck areas.
Rust statically links everything except for the C standard library by default, typically resulting in a single binary with all dependencies included. This makes it just as well-suited for serverless or container-based deployments. The compile times are quite a bit longer for Rust programs because the compiler is doing much more work than Go, however. The resulting binaries are smaller because of aggressive optimizations, the extremely minimal runtime library, the omission of garbage collection, and LLVM dead-code elimination.
Maintaining
The maintenance phase is when your application is now live in production and is seeing active use. The focus shifts away somewhat from building effective abstractions and tools and towards making changes quickly with confidence and ensuring good performance. Here the team reaps the rewards of any up-front investment in quality, reliability, maintainability, and observability and settles in for a long-term relationship with their codebase.
Go
With Go, my team and I keep discovering more “gotchas” - things that appeared to be working smoothly at first glance but hid subtle bugs that disrupted production behavior. Null-pointer errors and accidental mutation are easy to encounter without strict discipline and a good understanding of memory allocation and pointers in Go. The complexity around pass-by-reference vs pass-by-value and how they interact with references is not obvious at all, and takes some in-depth research to understand.
Because empty “zero-values” are automatically assigned when objects are constructed, it can be hard to deal with required properties and dealing with external concepts like “null vs. undefined” can be challenging to navigate. Linting tools can help, but it can be difficult to understand why they failed at times and documentation can be hard to find. In addition, Go’s more limited package management doesn’t even support dev-only dependencies - I need to carefully document them in the README file so that each new engineer knows to go install
them one-by-one.
As I’ve learned about these pitfalls over time my confidence level with Go has actually decreased, and I’ve adapted by being much more defensive and always being on the lookout for unintended consequences. Code review needs to be thorough and thoughtful with a good understanding of how the pieces fit together.
That being said, Go does truly get out of my way and allow me to charge ahead and do what I want. It boasts better performance than contemporaries like TypeScript or Python and easy concurrency without a strong learning curve, and I can move forward quickly and rapidly iterate without solving too many fiddly type errors along the way. The trade-off is that extensive unit and integration testing is needed in order to gain true confidence in what I’m deploying, and I’m always watching logs and tracing to spot unexpected issues because they do come up from time to time.
Rust
My long-term experience with Rust has been much more positive, and my confidence grows steadily over time as I understand the compiler errors better and learn how to avoid them in the first place. I agree with the sentiment I’ve heard from many developers that their work with Rust helps them build healthy habits and benefits them in other language ecosystems as well. Working with Rust long-term truly feels like it makes me a better programmer in general, with a greater awareness of memory management that follows me back to my work in other languages.
The type system requires a level of completeness that can at first be an annoyance to developers familiar with more loose and dynamic type systems, but ultimately it pushes bugs out of production and up into the development phase so that I can deal with them before they impact real users. The overall result is typically longer development cycles at first, but shorter stabilization and bug-fixing cycles over time.
I don’t have to think about performance as much because of Rust’s avoidance of general garbage collection, focus on abstractions that have zero runtime cost, and because of the highly optimized memory layouts of core data structures in the standard library. It has a very lean runtime footprint, using dramatically less memory than garbage-collected alternatives. Its concurrency model takes advantage of hardware threads and allows for shared memory with confidence, but provides the same kind of inter-process channel communication that Go does when needed.
I also appreciate Rust’s more robust packaging and dependency management through the excellent included Cargo command-line tool, which provides great functionality out of the box and can be extended with additional community tools to help manage builds and deployments.
What Should I Choose?
The choice between Go and Rust will depend on several different factors.
Is your team used to dynamic languages with simple type systems that abstract away memory management and concurrency? That may be a reason to lean towards Go and emphasize comprehensive test coverage at multiple layers to compensate for the looseness of the compiler. On the other hand, if your team has a good tolerance for sophisticated type system abstractions and is comfortable learning about memory layouts they may greatly appreciate the tools that Rust provides to enforce good behavior and gain easy performance wins.
Does your use case require a high level of precision and performance or is it very resource constrained? Rust’s lean system resource footprint and its ability to run well on embedded platforms without access to operating system helpers makes it uniquely suited for lightweight applications, and the compiler does a large amount of work during the build phase to gain confidence during runtime without adding overhead and impacting performance.
Do you need to rapidly iterate and experiment with different approaches without solving for a lot of edge cases or type errors? If reliability isn’t the main concern and bugs can typically be worked around, Go is a better choice because of the much more forgiving compiler and the silent auto-initialization of properties at zero-values - allowing your code to be more concise and implicit.
Go has been around longer and enjoys a wider talent pool, while Rust draws in a smaller and more specialized crowd that are particularly invested in correctness and efficiency. They both have thriving ecosystems, but Go typically has more library options available due to its age. Rust enjoys an advantage over Go, however, when it comes to packaging and dependency management with its excellent built-in Cargo tool.
Do you want to keep using your existing language ecosystem and just reach for a language with low-level tools for handling resource-intensive or critical portions of your application? Rust enjoys a solid lead here with many ways to embed Rust modules as add-ons in other languages and make calls between language runtimes. Go works very well if you’re fine communicating with it via HTTP calls, however, so in certain cases it can fill the same niche in a different way.
Ultimately the choice depends on your team now and how it will grow, what’s important for your application and the phase of life it is currently in, which characteristics best support the use cases you’ll be deploying your application to handle, and your product’s sensitivity to security threats and potential downtime. Both languages provide strong tools for performance and developer productivity, but they take drastically different approaches that will heavily impact your team’s long-term experience with them.
If you need help making the choice or building on your chosen platform, Nearform is here to help! We partner with global clients and help them achieve ambitious goals, enabling digital transformation by building highly effective platforms to power innovative experiences that will surprise and delight your users! We can partner with you at every phase of your project from idea to implementation and beyond, across the full stack and within multiple language ecosystems. Let us know how we can help empower your team!