Coming to Rust from Go

Key Differences and Similarities

Rust enforces memory safety and ownership at compile time, whereas Go relies on a runtime garbage collector. Rust uses explicit ownership rules and pattern matching, while Go uses interfaces and goroutines for concurrency.

The mental shift

You spend a week building a web service in Go. The code compiles on the first try. You run it, hit a few endpoints, and it works. You love how the language gets out of your way. Then you try the same thing in Rust. The compiler rejects your first draft. It rejects your second draft. It feels like arguing with a strict librarian who knows exactly where every book belongs.

That friction is the point. Go trades compile-time guarantees for a runtime garbage collector and a simple mental model. Rust trades runtime convenience for compile-time proofs. You are not just learning a new syntax. You are swapping a safety net for a blueprint. The compiler will not let you ship code that violates memory rules or creates data races. It forces you to think about data flow before you ever run the program.

Memory management: ownership versus garbage collection

Go relies on a garbage collector to reclaim memory. You allocate a slice, pass it around, and eventually the runtime figures out when it is safe to delete. Rust has no garbage collector. Instead, it uses a system called ownership. Every value has exactly one owner. When the owner goes out of scope, the value is dropped immediately. You can share values by borrowing them, but the compiler tracks every borrow to ensure no two parts of the program write to the same memory at once.

Think of Go like a hotel with a nightly cleaning crew. Guests leave towels and suitcases behind. The crew walks through, checks which rooms are empty, and clears them out. Rust is like a library with a strict checkout desk. You take a book, the desk logs your name. You return it, the desk crosses your name off. If you try to check out a book that is already logged to someone else, the desk stops you. No crew needed. No pause while the system scans for abandoned items.

/// Demonstrates ownership transfer and borrowing rules.
fn main() {
    let s1 = String::from("hello"); // s1 owns the heap data.
    let s2 = s1; // Ownership moves to s2. s1 is now invalid.
    
    // println!("{}", s1); // E0382: use of moved value.
    println!("{}", s2); // s2 is the only valid owner.
}

When you assign s1 to s2, Rust does not copy the underlying string data. It moves the pointer and the length and capacity metadata. The old variable becomes inert. This prevents double-free bugs without a runtime scanner. If you actually need two independent copies, you call .clone(). The community convention is to write s1.clone() explicitly. It signals to readers that you intentionally paid the cost of a deep copy.

Borrowing lets you pass references without taking ownership. You can have many immutable references (&T) or exactly one mutable reference (&mut T). The compiler enforces this rule at compile time. If you try to hold a mutable reference while an immutable one is still active, you get E0502 (cannot borrow as mutable because it is also borrowed as immutable). The fix is usually to narrow the scope of the immutable borrow or restructure the data flow. Trust the borrow checker here. It is catching a data race before it happens.

Error handling: results versus panics

Go handles errors by returning them as a second value. You check if err != nil on almost every line. It is explicit, but it creates boilerplate. Rust uses an enum called Result<T, E>. It holds either a success value or an error. You propagate errors with the ? operator, which returns early if the operation fails.

/// Reads a configuration file and returns its contents.
fn read_config(path: &str) -> Result<String, std::io::Error> {
    let mut file = std::fs::File::open(path)?; // Propagates open errors early.
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // Propagates read errors.
    Ok(contents) // Wraps success in the Ok variant.
}

The ? operator replaces the nested if err != nil chains. It makes the happy path read like a straight line. When a function returns Result, the caller must handle it. You can use .unwrap() to panic on failure, or .expect("message") to panic with a custom note. In production code, avoid .unwrap(). It turns a recoverable error into a crash. Use ? to bubble the error up to a place where you can actually handle it.

Go developers often reach for panic when things go wrong. Rust treats panics as exceptional, unrecoverable failures. Normal errors flow through Result or Option. This distinction keeps your error handling predictable. You know exactly which functions can fail and which ones will crash the program. The compiler enforces this boundary. You cannot accidentally ignore a Result without an explicit discard (let _ = ...). That underscore is a convention signal: you saw the error, you considered it, and you chose to drop it.

Concurrency: goroutines versus threads and async

Go ships with goroutines and channels. Goroutines are lightweight threads managed by a user-space scheduler. You spawn thousands of them with go func(). Rust does not have a built-in goroutine equivalent. It gives you OS threads via std::thread, and it provides an async/await model for multiplexing many tasks over a few threads.

use std::thread;
use std::sync::mpsc;

/// Spawns a worker thread and sends a message back.
fn main() {
    let (tx, rx) = mpsc::channel(); // Creates a typed sender and receiver.
    
    // Spawns an OS thread. The closure must own or borrow 'static data.
    let handle = thread::spawn(move || {
        let msg = String::from("hello from thread");
        tx.send(msg).unwrap(); // Sends value into the channel.
    });
    
    let received = rx.recv().unwrap(); // Blocks until a message arrives.
    println!("{received}");
    handle.join().unwrap(); // Waits for the thread to finish.
}

Rust channels work similarly to Go channels, but they are typed and enforced by the compiler. The tx sender can be cloned and moved into threads. The rx receiver stays in the main thread. When the last tx is dropped, the channel closes. This prevents deadlocks caused by forgotten senders.

For high-concurrency I/O, Rust developers use async runtimes like Tokio. Async tasks are not OS threads. They are state machines that yield control when they hit a network or disk operation. The runtime polls thousands of tasks on a handful of OS threads. This gives you goroutine-like scalability without a garbage collector. The tradeoff is that async code requires understanding lifetimes and pinning. You cannot hold a mutable reference across an .await point. The compiler will reject it with a "temporary value dropped while borrowed" error. The fix is usually to clone the data or restructure the state machine. Treat async boundaries as transaction commits. Plan your data ownership before you cross them.

Polymorphism: interfaces versus traits

Go uses implicit interfaces. If a type implements the methods an interface requires, it satisfies the interface. No declaration needed. Rust uses explicit traits. You define a trait, then you implement it for your types. You pass trait bounds to functions to accept any type that implements the trait.

/// Defines a behavior that multiple types can share.
trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    content: String,
}

// Explicitly opt into the trait contract.
impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{}: {}", self.title, &self.content[..10])
    }
}

/// Accepts any type that implements Summary.
fn notify(item: &impl Summary) {
    println!("Breaking news: {}", item.summarize());
}

Explicit traits give you control over when a type satisfies a contract. You cannot accidentally satisfy an interface by adding a method with the same name. This prevents namespace collisions and makes dependencies clear. When you see &impl Summary, you know exactly what capabilities the function expects. If you pass a type that does not implement the trait, the compiler rejects it with E0277 (trait bound not satisfied). The error message tells you exactly which trait is missing and points to the implementation block.

Go developers often miss the convenience of implicit interfaces. Rust compensates with derive macros and blanket implementations. You can write #[derive(Debug, Clone)] to generate boilerplate automatically. The standard library provides traits like Iterator and Send that work across the entire ecosystem. Once you learn the trait system, you write less glue code and get stronger guarantees. Read the trait bounds like a contract. They tell you exactly what a function can do with your data.

When to pick which

Use Go when you need rapid prototyping and simple deployment. Use Go when your team values straightforward code over compile-time guarantees. Use Go when you are building internal tools, microservices, or CLI utilities where a garbage collector pause is acceptable.

Use Rust when you need predictable performance and zero-cost abstractions. Use Rust when you are building systems software, game engines, or high-frequency trading platforms where memory layout matters. Use Rust when you want the compiler to catch data races and null pointer dereferences before they reach production.

Pick Rust's async model when you need to handle tens of thousands of concurrent connections on limited hardware. Reach for Go's standard library when you want a batteries-included experience without managing third-party dependencies. Trust the borrow checker when it complains. It usually has a point.

Where to go next