Home/Go vs Rust

Article · Systems engineering · 11 October 2026

Go vs Rust: When to Use Each (150 LeetCode Problems)

Go lets you ship the algorithm. Rust makes you prove who owns every byte of it. After weeks of solving the same problems in both, here is the decision framework I wish I had started with.

I have written Go professionally for years: Kubernetes operators, CLI tooling, the unglamorous services that keep a deployment pipeline breathing. So when I started working through LeetCode’s Top Interview 150 in Rust, I assumed the algorithms would be the hard part.

They weren’t. The algorithms were the easy part. The hard part was a linked list.

This is not another benchmark post. There are plenty of Go vs Rust articles that run a JSON parser in both languages and declare a winner by a few milliseconds. If you are weighing the Rust programming language against the Go programming language for your next project, this piece is about something more durable: what each language demands from you, what it gives back, and how to recognise which trade you are facing before you commit a team to it. Every example below comes from a problem I solved, and every snippet compiles.

Choosing between Go and Rust for a real system, not a LeetCode problem? I build production services in both: Go control planes, Kubernetes operators and CLIs, and Rust where the latency budget or the memory-safety bar demands it. Thirty minutes, no charge. You describe the system and I tell you which language fits, or whether you need both.

Hire Rust & Go developers · Book a consultation call

Ownership vs nil: the same function in Go and Rust

Start with the simplest tree problem on the list, Maximum Depth of a Binary Tree (LeetCode 104). In Go (still often called Google Go, after the company that created it):

func maxDepth(root *TreeNode) int {
    if root == nil {
        return 0
    }
    return 1 + max(maxDepth(root.Left), maxDepth(root.Right))
}

A nil pointer is the base case and recursion does the rest. You can read it aloud, and you can paste it straight into the Golang Playground at go.dev/play to run it.

Now the Rust version, using the signature LeetCode gives you:

pub fn max_depth(root: Option<Rc<RefCell<TreeNode>>>) -> i32 {
    match root {
        None => 0,
        Some(node) => {
            let node = node.borrow();
            1 + max_depth(node.left.clone()).max(max_depth(node.right.clone()))
        }
    }
}

The algorithm is identical. What changed is the type: Option<Rc<RefCell<TreeNode>>>. The first time I saw it, I read it as noise. It took a while to see that it is a sentence, and every word in it is a contractual claim:

Go answers all three questions implicitly. Any pointer can be nil, any number of things may point at a node, anything may mutate it, and the garbage collector reclaims memory once nothing refers to it. Rust declines to answer those questions on your behalf. That single distinction explains almost everything else in this article.

One more detail cost me days. Look at the last line of the Rust function: there is no return. In Rust, a block evaluates to its final expression, so 1 + l.max(r) is not a statement that computes something and throws it away. It is the function’s answer. If you come from Go or Python and keep asking “where does the number actually come from?”, that is the reason.

Why linked lists are hard in Rust

If trees are where Rust becomes verbose, singly linked lists are where it becomes adversarial. Take Add Two Numbers (LeetCode 2): two numbers stored as linked lists of digits in reverse order; return their sum as a list.

In Go, this is a direct transcription of the paper solution:

func addTwoNumbers(l1, l2 *ListNode) *ListNode {
    dummy := &ListNode{}
    tail, carry := dummy, 0
    for l1 != nil || l2 != nil || carry > 0 {
        sum := carry
        if l1 != nil {
            sum += l1.Val
            l1 = l1.Next
        }
        if l2 != nil {
            sum += l2.Val
            l2 = l2.Next
        }
        carry = sum / 10
        tail.Next = &ListNode{Val: sum % 10}
        tail = tail.Next
    }
    return dummy.Next
}

In Rust, the same idea:

pub fn add_two_numbers(
    mut l1: Option<Box<ListNode>>,
    mut l2: Option<Box<ListNode>>,
) -> Option<Box<ListNode>> {
    let mut head: Option<Box<ListNode>> = None;
    let mut tail = &mut head;
    let mut carry = 0;
    while l1.is_some() || l2.is_some() || carry > 0 {
        let d1 = l1.as_ref().map_or(0, |n| n.val);
        let d2 = l2.as_ref().map_or(0, |n| n.val);
        let sum = d1 + d2 + carry;
        carry = sum / 10;
        *tail = Some(Box::new(ListNode::new(sum % 10)));
        tail = &mut tail.as_mut().unwrap().next;
        l1 = l1.and_then(|n| n.next);
        l2 = l2.and_then(|n| n.next);
    }
    head
}

Both produce [7, 0, 8] for [2,4,3] + [5,6,4]. The Rust version is not much longer, but every line carries a decision the Go version never asks you to make:

In Go, you only ever ask where does this point? In Rust, you also ask am I looking at this, or am I taking it?

take() vs as_ref(): the bug that compiled

While debugging this problem, I wrote the following line to read the second digit:

let p1 = n.next.take().as_ref().unwrap().val;

It compiled. It produced the correct number. It also deleted the rest of my list. I attached a Drop implementation that prints when a node is freed, and the output made it obvious:

before: n.val=2, tail=Some(4)
   >>> DROPPED node 4
   >>> DROPPED node 3
after : p1=4, n.next=None

Here is what happened. take() is not a read. It moves the value out of n.next and leaves None in its place. The value it returns is a temporary that nothing owns. .as_ref() borrows that temporary, .val copies the integer out, and at the semicolon the temporary is dropped. Because a Box owns its contents, dropping node 4 drops node 3 as well. The borrow at the end of the chain came after the move had already happened, so it could not save anything.

The fix is to borrow without taking:

let p1 = n.next.as_ref().unwrap().val;         // list intact
let p1 = n.next.as_ref().map_or(0, |b| b.val); // and no panic on None

I now keep this distinction pinned above my desk:

expressionn.next afterwardswhat you getuse it to
n.next.as_ref()unchangeda borrowlook
n.next.take()Noneownershipmove it out

The episode taught me something important about what Rust’s guarantees mean. Rust did not prevent my logic error. It prevented the consequence that would have followed in C: a dangling pointer to freed memory that might have worked for months before corrupting something. In Rust, the freed nodes were provably unreachable. Memory safety is not correctness. It removes one entire class of bugs, and that class happens to be the expensive one.

Why Rust has no garbage collector: memory safety explained

When I first asked why Rust makes you fight over a three-line linked-list walk that Python handles in plain English, the answer turned out to be security, not pedantry.

Consider the classic use-after-free. A program frees an object but keeps a pointer to it. Later, an attacker coaxes the allocator into placing data they control at that same address, a technique called heap grooming. When the program calls through the stale pointer, it is now executing on the attacker’s terms. Chained with further techniques, this leads to arbitrary code execution.

This is not hypothetical. Microsoft has reported that roughly 70% of the vulnerabilities it assigns CVEs to each year are memory-safety issues, and Google’s Chromium team has published a similar figure for its serious security bugs. Rust’s ownership rules turn that entire category into compile errors.

Is Go memory safe?

Yes — and this is the part that most Go vs Rust debates miss. Go prevents use-after-free with a garbage collector. Memory is never released while anything can still reach it, so a stale pointer cannot exist. Rust prevents the same bug at compile time, by proving that no reference outlives the value it points to.

Both are safe. The difference is when the safety is enforced and what it costs. (One honest qualification: Go’s guarantees can break down under data races, where unsynchronised writes to an interface or slice header can be observed half-finished. Run your tests with go test -race. Rust’s type system rejects data races at compile time through the Send and Sync traits.)

So Rust’s unique proposition is narrower than “safety”. It is memory safety without a garbage collector.

Go garbage collector latency: what a GC pause actually costs

A garbage collector periodically works out which objects are no longer reachable and reclaims them. Go’s collector is concurrent and heavily tuned; its stop-the-world phases are typically well under a millisecond. For most web services, that is invisible next to a 20-millisecond database query.

It stops being invisible when you care about the tail of your latency distribution rather than the average.

Discord’s engineering team described this in 2020. Their Read States service, written in Go, showed latency spikes roughly every two minutes. The Go runtime they were using forced a collection cycle at least that often, and the service held a large in-memory cache that the collector had to scan each time. Rewritten in Rust, where memory is freed deterministically as values go out of scope, the spikes disappeared. Nothing was “wrong” with the Go version. It simply paid a periodic tax that the service’s latency profile could not absorb.

My rule of thumb: if you can state your p99 latency budget in microseconds, a garbage collector is part of your threat model. If you state it in milliseconds, it almost certainly is not.

Seeing periodic latency spikes in a Go service? Before you rewrite anything, it is worth knowing whether the collector is actually the cause. Usually it is heap shape, a cache the GC has to scan, or allocation churn on the hot path, and those are often fixable in Go. When the tail genuinely needs Rust, I scope the smallest piece worth rewriting. Thirty minutes, no charge.

Hire Rust & Go developers · Book a consultation call

When to choose Go

It would be easy to read the above and conclude that Rust is strictly better. It isn’t. For most of the software I have shipped, Go was the right choice, for reasons that have nothing to do with benchmarks.

Time to correct code

In Go, once the algorithm works on paper, writing the code is translation. In Rust, there is a second translation into ownership, and for pointer-heavy structures it is the harder of the two. During my LeetCode run, I started solving the linked-list problems in Go first, then porting them. That separated “what is the algorithm?” from “how do I express this ownership?” and made both easier.

Concurrency you can explain to a new hire

The heart of Golang concurrency is that a goroutine costs only a few kilobytes, so you write plain blocking code and the runtime parks the goroutine and schedules another one underneath. The blocking and non-blocking distinction that dominates other languages mostly disappears. Goroutines and channels make fan-out trivial:

// fetch(url string) string is any blocking call, e.g. an HTTP GET
func fetchAll(urls []string) []string {
    results := make(chan string)
    for _, u := range urls {
        go func(u string) { results <- fetch(u) }(u)
    }
    out := make([]string, 0, len(urls))
    for range urls {
        out = append(out, <-results)
    }
    return out
}

Rust’s async ecosystem, built on runtimes such as Tokio, is powerful and very fast. It also asks you to choose a runtime and to reason about Send, 'static lifetimes and pinning. Those are real costs for a team.

Operational simplicity

Fast compiles, a single static binary, painless cross-compilation and a standard library that covers HTTP, JSON and testing out of the box.

The cloud-native ecosystem

Kubernetes, Docker, Terraform, Prometheus and etcd are written in Go. If you are writing an operator, a controller or a CLI that talks to that world, the first-party client libraries are in Go, and so are the people who maintain them.

Onboarding

Learning Go is fast: a competent engineer becomes productive within a week or two. Fluency in Rust takes months. I can testify to that personally.

Don’t have months for your team to get fluent in Rust? You don’t need to. I can build the Rust component, the Go services around it, or both, and hand them over with the documentation and code review your team needs to own them. You get the guarantees now and grow the skills on your own schedule.

Hire Rust & Go developers · Book a consultation call

When to choose Rust

Latency-critical systems with tight tails

Proxies, matching engines, real-time pipelines, game servers. In high-frequency trading, C++ is still the incumbent and the very lowest tick-to-trade paths run on FPGAs, but Rust is gaining ground in trading infrastructure for exactly the reason above: predictable latency with no collector in the loop.

Embedded and bare-metal

With no garbage collector and no mandatory runtime, Rust runs on microcontrollers with a few kilobytes of RAM. The ecosystem is mature: no_std for code without an operating system, embedded-hal for portable drivers, Embassy for async on bare metal, RTIC for interrupt-driven real-time scheduling, and probe-rs for flashing and debugging. TinyGo exists, but it supports a subset of the language and its GC is still a GC.

Libraries embedded in other languages

A Rust library carries no runtime, so it slots into Python, Node or C with minimal friction. Through PyO3, it now powers a generation of Python tooling: Polars, Ruff and uv are all Rust underneath. Shipping a Go library inside another process means shipping the Go runtime with it.

WebAssembly

Small binaries with no collector to bundle.

Domains where invariants matter more than velocity

Parsers, cryptography, operating-system components. Rust has been accepted into the Linux kernel and is used in Android for new low-level code, precisely because it removes memory-safety bugs at their source.

Go or Rust? A five-question decision framework

Both languages are open source, free, mature and backed by large communities, so licensing and longevity rarely decide anything. When someone asks me “Go or Rust?”, I ask five questions instead of answering.

  1. What is your latency budget, and do you care about the mean or the tail? Milliseconds and averages point to Go. Microseconds and p99.9 point to Rust.
  2. Where does it run? A microcontroller, a browser via WebAssembly, or inside another language’s process all point strongly to Rust. A container in a Kubernetes cluster is Go’s home turf.
  3. How much of the program is glue? If most of the code calls APIs, moves JSON around and orchestrates other systems, Go’s simplicity compounds. If most of it is a tight core of data structures and algorithms, Rust’s guarantees compound.
  4. Who maintains it in two years? A language your team can read under pressure at 3 a.m. beats a language that is theoretically superior. Be honest about the learning curve.
  5. What does a bug cost? If a memory-corruption bug means a security incident, a recall or a lost trade, the up-front cost of Rust’s compiler is cheap insurance.

In short:

Ran the five questions and landed on Go, Rust, or both? Bring me your answers. In thirty minutes I’ll pressure-test the decision against your actual latency budget, deployment target and team, and tell you what I would build first. If you need engineers to build it, that’s the work I do.

Hire Rust & Go developers · Book a consultation call

Learning Rust: ownership, borrowing, and moves

If you decide Rust is worth it, here is what I wish I had been told on day one. Almost every compiler error I hit was the answer to one of three questions I had not asked:

Learn which operations move a value and which do not, and most “use of moved value” errors stop being mysterious:

These MOVE it:      let b = a;   f(a);   match a { … }   for x in v
These do NOT:       f(&a);   match &a { … }   a.as_ref()   a.clone()
These EXCHANGE it:  a.take()   std::mem::replace(&mut a, new)

That last row is the subtle one. You cannot move a value out from behind a mutable reference, because that would leave the owner holding nothing. You can swap something valid in its place, which is all take() does: it hands you the value and leaves None behind. Once I understood that rule, half the borrow checker’s complaints became predictable.

Two habits mattered more than any syntax:

Conclusion: where you pay

The Go vs Rust question is less a contest than a choice of where to pay. Go optimises for the time between an idea and a running service, and pays for it at runtime with a garbage collector. Rust optimises for everything that happens after the code ships, and you pay for it at compile time, in your own attention.

The Go language trusts the runtime. The Rust language trusts the compiler, and it makes you earn that trust one ownership decision at a time.

After 150 problems, I don’t think one is better. I think they answer different questions. If the hard part of your problem is coordination, write it in Go. If the hard part is the bytes, write it in Rust.

Work with me. I design and build production systems in Go and Rust: Kubernetes operators and controllers, CLIs and platform tooling, low-latency services, and Rust components that slot into Go or Python codebases. Ten years on distributed systems and AWS. Book a free thirty-minute call. You describe what you’re building and where it hurts, and I tell you which language, which architecture, and what I would change first. If you need hands on the code, I can build it with you.

Hire Rust & Go developers · Book a consultation call

I post what I’m learning in Rust, Go and systems engineering on X at @c199benzene, including the parts that hurt.

Published 11 October 2026