Rust for Go Developers
Rust 1.82 through the lens of Go: ownership vs garbage collection, traits vs interfaces, error handling, and where the mental models transfer.
Rust for Go Developers
Quick Overview
Rust is a systems programming language that gives you C-level performance and control without a garbage collector — but unlike C, it enforces memory safety at compile time via its ownership system. If you’ve been writing Go, you already know the important things: static types, compiled binaries, good concurrency primitives, and minimal runtime. The difference is that Go’s garbage collector makes memory management invisible; Rust makes it explicit and verifiable. Rust 1.82 is the current stable release (released October 2024). Install it with rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Installs rustup, rustc, and cargo — the Rust equivalent of go toolchain
Getting Started
# Create a new project — like `go mod init`
cargo new myapp
cd myapp
# Build and run — like `go run .`
cargo run
# Build a release binary — like `go build`
cargo build --release
# Run tests — like `go test ./...`
cargo test
# Add a dependency — like `go get`
cargo add serde
// src/main.rs — the equivalent of package main / func main()
fn main() {
let message = String::from("hello from Rust");
println!("{}", message);
}
Cargo.toml is your go.mod + go.sum. Dependencies live in [dependencies]. The crates.io registry is to Rust what pkg.go.dev is to Go. The standard library is smaller than Go’s by design — you’ll reach for crates more often.
Core Concepts
Ownership — the thing you don’t have in Go
This is the central difference. Go has a GC; Rust has ownership rules enforced at compile time. The compiler guarantees there are no use-after-free bugs, no double frees, and no data races — without any runtime overhead.
Three rules:
- Every value has exactly one owner
- When the owner goes out of scope, the value is dropped (freed)
- There can be either many immutable references or one mutable reference — never both simultaneously
// Ownership transfer (move semantics)
let s1 = String::from("hello");
let s2 = s1; // s1 is MOVED into s2 — s1 is no longer valid
// println!("{}", s1); // compile error: s1 was moved
// Borrowing — pass a reference, keep ownership
fn print_len(s: &String) {
println!("{}", s.len());
}
let s = String::from("hello");
print_len(&s); // borrow s — s is still valid after this call
println!("{}", s); // fine
Go developers: think of a move as passing a channel that closes after one send. Once you’ve moved a value, the original binding is gone. Borrow = temporary read access with no ownership transfer.
Types — familiar but stricter
| Go | Rust | Notes |
|---|---|---|
int | i64, i32, i8, u64… | Explicit width and sign, always |
string | String / &str | String = owned heap string; &str = borrowed string slice |
[]T | Vec<T> | Growable heap-allocated list |
[N]T | [T; N] | Fixed-size array — size is part of the type |
map[K]V | HashMap<K, V> | In std::collections |
struct | struct | Same concept, different syntax |
interface | trait | Behavioral contracts — see below |
error | Result<T, E> | Errors are values, but the type system enforces handling |
nil | Option<T> | No null — use Some(value) or None |
Traits vs interfaces
Go interfaces are implicit — any type that has the right methods satisfies the interface. Rust traits are explicit — you implement a trait for a type.
// Define a trait — like a Go interface
trait Greeter {
fn greet(&self) -> String;
}
struct Person {
name: String,
}
// Explicit implementation — unlike Go's implicit satisfaction
impl Greeter for Person {
fn greet(&self) -> String {
format!("Hello, I'm {}", self.name)
}
}
// Use a trait as a parameter — like `func foo(g Greeter)` in Go
fn say_hello(g: &impl Greeter) {
println!("{}", g.greet());
}
Error handling
Go: if err != nil. Rust: Result<T, E> with the ? operator, which is Go’s if err != nil { return err } collapsed to one character.
use std::fs;
use std::io;
// Return type says: success is String, failure is io::Error
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // ? = return Err if this fails
Ok(contents)
}
// Caller handles the Result
fn main() {
match read_file("config.txt") {
Ok(contents) => println!("{}", contents),
Err(e) => eprintln!("Failed: {}", e),
}
}
Essential Syntax
Structs and methods
// Define a struct
struct Server {
host: String,
port: u16,
}
// Methods live in impl blocks — equivalent to Go methods on a type
impl Server {
// Associated function (no self) — like a Go constructor
fn new(host: &str, port: u16) -> Self {
Server {
host: host.to_string(),
port,
}
}
// Method with immutable self — like Go's value receiver (roughly)
fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
// Method with mutable self — requires the binding to be `mut`
fn set_port(&mut self, port: u16) {
self.port = port;
}
}
let mut s = Server::new("localhost", 8080);
s.set_port(9090);
println!("{}", s.address());
Enums and pattern matching
Rust enums carry data — they’re closer to Go’s tagged unions than to Go’s iota constants.
// Enum variants can hold different data
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
}
// Pattern matching is exhaustive — the compiler ensures you handle all variants
fn handle(msg: Message) {
match msg {
Message::Quit => println!("quit"),
Message::Move { x, y } => println!("move to {},{}", x, y),
Message::Write(text) => println!("write: {}", text),
}
}
Option — replacing nil checks
// Option<T> is either Some(T) or None — there is no null
fn find_user(id: u32) -> Option<String> {
if id == 1 { Some("Alice".to_string()) } else { None }
}
// Unwrap with a default
let name = find_user(99).unwrap_or("unknown".to_string());
// Map over the value if it exists — like Go's optional chaining you wish existed
let upper = find_user(1).map(|n| n.to_uppercase());
// Use ? in functions that return Option
fn get_email(user_id: u32) -> Option<String> {
let name = find_user(user_id)?; // returns None if find_user returns None
Some(format!("{}@example.com", name.to_lowercase()))
}
Closures and iterators
let numbers = vec![1, 2, 3, 4, 5];
// map + filter + collect — like Go slices but lazy and composable
let result: Vec<i32> = numbers
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 10)
.collect();
// result = [20, 40]
// Closures capture their environment
let multiplier = 3;
let tripled: Vec<i32> = numbers.iter().map(|&x| x * multiplier).collect();
Common Patterns
Pattern 1: CLI tool (the Go use case Rust excels at)
// Cargo.toml — add these dependencies
// [dependencies]
// clap = { version = "4", features = ["derive"] }
use clap::Parser;
use std::fs;
#[derive(Parser)]
#[command(about = "Count words in a file")]
struct Args {
/// Path to the file
path: String,
/// Count lines instead of words
#[arg(short, long)]
lines: bool,
}
fn main() {
let args = Args::parse();
let content = fs::read_to_string(&args.path)
.unwrap_or_else(|e| {
eprintln!("Error reading {}: {}", args.path, e);
std::process::exit(1);
});
if args.lines {
println!("{}", content.lines().count());
} else {
println!("{}", content.split_whitespace().count());
}
}
Pattern 2: Concurrent work with channels
Rust has Go-style channels (std::sync::mpsc) and a thread model that’s more explicit but similarly ergonomic for simple cases.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
// Spawn workers
for i in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
// `move` captures `i` by value — required because the thread might outlive the scope
let result = i * i;
tx.send(result).unwrap();
});
}
drop(tx); // drop the original sender so the channel closes when all clones are dropped
// Collect results
let results: Vec<i32> = rx.iter().collect();
println!("{:?}", results);
}
For async/await (the equivalent of Go’s goroutines for I/O-bound work), use tokio:
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handles: Vec<_> = (0..4).map(|i| {
tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
println!("task {} done", i);
})
}).collect();
for h in handles {
h.await.unwrap();
}
}
Pattern 3: Custom error types (the production pattern)
// Cargo.toml: thiserror = "1"
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("user {id} not found")]
NotFound { id: u32 },
#[error("invalid input: {message}")]
Validation { message: String },
}
// ? automatically converts sqlx::Error into AppError::Db
async fn get_user(id: u32) -> Result<User, AppError> {
let user = db.fetch_optional("SELECT * FROM users WHERE id = $1", id)
.await?; // sqlx::Error is converted via #[from]
user.ok_or(AppError::NotFound { id })
}
Gotchas & Tips
The borrow checker is right. When the compiler rejects your code, the usual reaction is to fight it with clone() everywhere. Don’t. Step back, think about who should own the data, and restructure. The compiler is telling you your design has a lifetime ambiguity, not being pedantic.
String vs &str trips up everyone. &str is a borrowed string slice — use it in function signatures when you don’t need ownership: fn greet(name: &str). String is owned heap memory — use it in structs. Call .to_string() or .to_owned() to convert. As of Rust 1.58+, format strings work directly: let s: String = format!("hello {name}");.
There’s no implicit interface satisfaction. In Go, implementing an interface is zero ceremony. In Rust, you must write impl Trait for Type. This is verbose but intentional — you always know exactly which traits a type implements, and the compiler can optimize aggressively.
unwrap() is panic!() in disguise. It’s fine in tests and quick scripts, but treat it like Go’s log.Fatal in production code — it terminates the program. Use ?, unwrap_or, or expect("reason") (which panics with a useful message) instead.
Cargo workspaces replace Go modules for monorepos. If you have multiple related crates, use a workspace: a root Cargo.toml with [workspace] pointing to member directories. cargo build and cargo test at the root build everything.
Lifetimes are rarer than tutorials suggest. You’ll mostly encounter them in struct definitions that hold references. For the first few months, if you’re hitting lifetime errors, restructure to own the data in the struct (String instead of &str, Vec<T> instead of &[T]) and the errors go away.
New in Rust 1.82: &raw const and &raw mut for creating raw pointers without going through a reference — relevant for unsafe code and FFI. The std::sync::LazyLock type stabilized, replacing the once_cell crate for global lazy initialization.
Next Steps
- The Book: doc.rust-lang.org/book — the official Rust book is genuinely excellent; chapters 4-6 (ownership, borrowing, enums) are the core
- Rustlings: github.com/rust-lang/rustlings — small exercises; the fastest way to internalize the borrow checker
- Tokio docs: tokio.rs — if you’re writing async Rust (servers, CLI tools with I/O), this is where to go after the basics
- Related cheatsheets: once you’ve got the basics, the
serdecrate (serialization) andsqlx(async database queries) cover 80% of backend use cases
Source: z2h.fyi/cheatsheets/rust-for-go-developers — Zero to Hero cheatsheets for developers.