Skip to content

Rust for Go Developers: A Practical Guide

If you write Go, you’ll find more of home in Rust than you might expect. Both languages compile to a single fast binary. Both have structs with methods instead of classes, no inheritance, errors returned as values instead of exceptions, a standard formatter everyone uses, and one command-line tool that builds, tests, and fetches packages. A Go struct with a few methods translates to Rust almost line by line.

What’s new is mostly about memory and about being explicit. Rust has no garbage collector: every value has one owner, and the compiler checks ownership and borrowing before your program runs. There is no nil (you use Option), and if err != nil turns into Result and the ? operator. Interfaces still exist (they’re called traits), but a type has to say out loud that it implements one. Expect to spend most of your learning time on ownership. The rest will feel like a stricter, more detailed Go.

Why do Go developers learn Rust? Usually for one of these reasons: a service where garbage-collection pauses or memory use start to matter, CPU-heavy work, code that runs without an operating system (embedded) or in the browser (WebAssembly), or simply to have the compiler catch data races and nil-style crashes before production. For a broader comparison of Rust with Go, C++, and Python, see Rust vs Other Languages. This page goes deeper and is written for you, the Go developer.

Use this table as a phrase book. The last column links to the lesson on this site that explains the Rust side.

Go Rust Learn more
Garbage collector Ownership and borrowing, checked at compile time; memory is freed when the owner goes out of scope Ownership, Borrowing
struct + methods struct for the data + an impl block for the methods Structs
Value receiver func (a Account) &self (reads, no copy) or self (takes ownership) Structs
Pointer receiver func (a *Account) &mut self (may change the value) Structs
NewAccount(...) constructor function An associated function, by convention Account::new(...) Structs
Capital letter = exported The pub keyword Modules
Zero values (var c Config) No implicit zero values; every field must be set. #[derive(Default)] + Config::default() Structs, Common Traits
Struct embedding No embedding. Put the struct in a field and write small forwarding methods, or share behavior with traits Traits, Design Patterns
interface (satisfied implicitly) trait (implemented explicitly with impl Trait for Type) Traits
[]Shape holding different types Vec<Box<dyn Shape>> (a trait object) Trait Objects
any / interface{} + type switch An enum with one variant per case, then match Enums, match
Generics func Max[T cmp.Ordered](a, b T) T Generics with trait bounds: fn max<T: PartialOrd>(a: T, b: T) -> T Generics, Advanced Generics
nil pointer, nil map, nil interface Option<T>: either Some(value) or None Option
v, ok := m[k] m.get(&k) returns an Option HashMaps
(T, error) + if err != nil { return err } Result<T, E> + the ? operator Result
errors.New, sentinel errors, errors.Is / errors.As An error enum you match on (the thiserror crate helps) Custom Errors, anyhow & thiserror
fmt.Errorf("...: %w", err) anyhow with .context("..."), or map_err anyhow & thiserror
panic / recover panic! for real bugs; recovering is rare (std::panic::catch_unwind) Panic
defer Drop, which runs when a value goes out of scope (RAII) Scope, Smart Pointers
go f() std::thread::spawn(...), or tokio::spawn(...) for async tasks Concurrency, Async/Await
chan T, make(chan T, n) std::sync::mpsc::channel() (unbounded) or sync_channel(n); tokio::sync::mpsc in async code Channels
select tokio::select! in async code, or the select! from crossbeam-channel Async in Depth
sync.Mutex next to the data Mutex<T> wraps the data; it unlocks itself when the guard is dropped Shared State
sync.WaitGroup JoinHandle::join(), or std::thread::scope Concurrency
sync.Once std::sync::OnceLock / LazyLock Std Idioms
context.Context No single equivalent: drop a future to cancel it, tokio::time::timeout, or CancellationToken from tokio-util Async in Depth
func values and closures Closures, described by the traits Fn, FnMut, FnOnce Closures, Fn, FnMut & FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() for loops, Iterators
The one for loop loop, while, and for Loops
Range-over-func iterators (iter.Seq) The Iterator trait, with adapters like filter, map, sum, collect Iterators, Custom Iterators
switch match (the compiler checks you covered every case) match, Pattern Matching
const + iota An enum (optionally with explicit numbers) Enums
:= let (immutable by default; let mut to allow changes) Variables
string (bytes, usually UTF-8) String (owned) and &str (borrowed), always valid UTF-8 Strings
rune, for _, r := range s char, for c in s.chars() Strings
[]byte Vec<u8> or &[u8] Slices
fmt.Println, %v, %+v println!, {} (Display), {:?} and {:#?} (Debug, with #[derive(Debug)]) Output
String() string (the Stringer interface) Implement Display (then .to_string() works for free) Common Traits
Struct tags `json:"user_name"` Attributes: #[serde(rename = "user_name")] Serde & JSON
int(x), float64(n) conversions as for numbers, From/Into, and TryFrom when it can fail Type Casting, Conversions
Integer overflow wraps silently Panics in debug builds, wraps in release; checked_add, wrapping_add, saturating_add say what you want Integer Overflow
*T and &x &T and &mut T references (never null, can’t outlive the value); Box<T> for an owned heap value Borrowing, Smart Pointers
Packages Modules (mod) inside a crate; use instead of import Modules
go generate Macros and build.rs build scripts Macros, Procedural Macros
unsafe package unsafe blocks and raw pointers Unsafe Rust
cgo extern "C" and FFI (bindgen generates bindings from C headers) FFI
Build tags //go:build linux #[cfg(target_os = "linux")] and Cargo features Cargo Features & cfg

Go has slices and maps built into the language. Rust has them in the standard library, plus a few more.

Go Rust Learn more
[]T that you own and append to Vec<T> Vectors
[]T passed to a function that only reads it &[T] (a slice borrowed from a Vec or an array) Slices
[N]T [T; N] Arrays
append(s, x) / append(s, other...) v.push(x) / v.extend(other) Vectors
s[a:b] &v[a..b] Slices
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) Vectors
map[K]V HashMap<K, V> HashMaps
map[K]struct{} or map[K]bool as a set HashSet<T> HashSet
Collecting and sorting map keys before printing BTreeMap<K, V> (keys stay sorted) HashMaps
container/list VecDeque<T> (push_back / pop_front) VecDeque & BinaryHeap
container/heap (implement 5 methods) BinaryHeap<T>: just push and pop; largest first, wrap in Reverse for smallest first VecDeque & BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) Sorting
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) Vectors
strings.Builder A String with push_str, or write! Strings
sync.Map Mutex<HashMap<K, V>>, RwLock, or the dashmap crate Shared State

Two things to know. First, like Go maps, a Rust HashMap has no fixed order, so sort before you print or use a BTreeMap. Second, the famous Go slice surprise (two slices sharing one backing array, and an append changing the other one) can’t happen in Rust: while one part of your code holds a &mut borrow of a Vec, nothing else can look at it.

Good news: you’re used to one tool that does everything, and Rust has one too. Cargo covers go build, go run, go test, go mod, and go install.

Go Rust Notes
go command cargo Builds, runs, tests, adds packages, publishes. See Cargo & Crates
go mod init cargo new my_app (cargo new --lib for a library) Creates the folder, Cargo.toml, src/main.rs, and a git repo
go.mod Cargo.toml
go.sum Cargo.lock Created and updated for you
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Updates within the version ranges in Cargo.toml
pkg.go.dev crates.io for packages, docs.rs for their API docs
go build / go run / go test cargo build / cargo run / cargo test Add --release for an optimized build; debug builds are much slower at runtime
go vet, staticcheck, golangci-lint cargo clippy Official, with hundreds of helpful lints
gofmt / goimports cargo fmt (rustfmt) One standard style, just like gofmt
go doc cargo doc --open Code examples in doc comments run as tests. See Documentation
go test -bench criterion (run with cargo bench) See Performance
go test -cover cargo llvm-cov or cargo tarpaulin
go test -race Not needed for safe code: data races are compile errors. Miri checks unsafe code
go test -fuzz cargo fuzz Install with cargo install cargo-fuzz
testify Built-in assert! and assert_eq!; pretty_assertions for nicer diffs See Testing and Testing Beyond the Basics
gomock / mockery mockall, or hand-written fakes that implement your trait
go install pkg@latest cargo install name
go work (workspaces) A Cargo workspace One Cargo.toml at the top lists the member crates
toolchain line in go.mod rustup and a rust-toolchain.toml file See Rust Tools & Setup
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... Often needs a linker for the target; cross or cargo-zigbuild make it easier
govulncheck cargo audit (RustSec advisories), cargo deny
gopls rust-analyzer Works in VS Code, Neovim, Helix, Zed, and more
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb, or CodeLLDB in VS Code
pprof cargo flamegraph, perf, or any native profiler
air (live reload) bacon or cargo watch

A small Cargo.toml next to the go.mod you know:

module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

One difference you’ll notice right away: Go’s standard library includes an HTTP server, JSON, templates, and crypto. Rust’s standard library is smaller on purpose, so things like JSON, HTTP, async, and random numbers come from crates. Adding one is a single cargo add, and a handful of crates (serde, tokio, clap, regex, rand) are used by almost everyone.

Libraries and frameworks: “What do we use instead of…?”

Section titled “Libraries and frameworks: “What do we use instead of…?””

This is the first practical question. “We use Gin and GORM. What’s the Rust version?” Here are the closest equivalents. Like Go, Rust usually has two or three good community choices instead of one official answer.

Go Rust Notes
Gin, Echo, Chi, Fiber axum (we build one in Project: Web API), actix-web, Rocket axum and actix-web are widely used in production
net/http server hyper (low level), usually used through axum No HTTP server in the standard library
http.Handler middleware tower layers and tower-http (CORS, compression, tracing, timeouts) axum uses tower directly
net/http client reqwest Async by default, with an optional blocking client. See Networking
grpc-go tonic (with prost for protobuf)
gorilla/websocket tokio-tungstenite, or axum’s ws feature
html/template, text/template askama (checked at compile time), tera, minijinja
net (TCP/UDP) std::net, or tokio::net for async See Networking
errgroup tokio::task::JoinSet
Go Rust Notes
encoding/json + struct tags serde + serde_json Also does TOML (toml crate) and other formats. See Serde & JSON
database/sql + pgx sqlx (async, plain SQL checked against your database at compile time), tokio-postgres Not related to Go’s jmoiron/sqlx, despite the name
sqlc sqlx’s query! macros Both start from SQL you write and give you typed results
GORM SeaORM (async, entity-style), Diesel (type-checked query builder) See Databases
go-sqlite3 rusqlite
go-redis redis
Go Rust Notes
cobra, urfave/cli, flag clap (derive your arguments from a struct) See CLI Arguments and Project: CLI with clap
viper config or figment Layer files and environment variables, then deserialize with serde
log/slog, zap, zerolog tracing + tracing-subscriber, or log + env_logger Structured logging. See Logging
regexp regex Same idea as Go’s RE2: linear-time matching, no backreferences. See Regex
time std::time for durations; chrono or jiff for dates and time zones See Date & Time
embed include_str! / include_bytes!, or rust-embed for whole folders
bubbletea ratatui Terminal UIs
Prometheus client_golang prometheus or metrics
sync/atomic std::sync::atomic
Go Rust Notes
Wails Tauri (web UI + Rust backend) Very similar idea. See GUI Apps
Fyne egui, Iced, Slint No first-party GUI toolkit
Ebitengine macroquad, Bevy Bevy is pre-1.0 and changes its API between releases. See Game Development
TinyGo embedded-hal, Embassy Rust runs without an OS and without a heap. See Embedded
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus Small binaries, no GC to ship. See WebAssembly

Side by side: structs and methods (almost the same)

Section titled “Side by side: structs and methods (almost the same)”

This is where Go developers feel at home. Here is a small account type in Go:

type Account struct {
Owner string // exported: capital letter
balance int // unexported
}
func NewAccount(owner string, balance int) *Account {
return &Account{Owner: owner, balance: balance}
}
func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int { return a.balance }
func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() {
account := NewAccount("Ada", 100)
account.Deposit(50)
fmt.Println(account)
fmt.Println("balance is", account.Balance())
}

And in Rust:

use std::fmt;
// `pub` does the job of Go's capital letter.
pub struct Account {
pub owner: String,
balance: i64, // private, like a lowercase field in Go
}
impl Account {
// Like Go's NewAccount: a plain function. `new` is only a convention.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// Like a pointer receiver: this method changes the account.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Reads only. No copy is made, unlike a Go value receiver.
pub fn balance(&self) -> i64 {
self.balance
}
}
// Go's String() method (the Stringer interface) becomes Display.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
println!("balance is {}", account.balance());
}

Output:

Ada: 150
balance is 150
▶ Try it Yourself

The shape is the same: data in a struct, methods attached to it, a constructor that’s just a function. The differences are small but important. Methods live together in an impl block. The receiver says exactly what the method does: &self reads, &mut self changes, and plain self takes the value and uses it up. And because deposit changes the account, the variable must be declared let mut. In Go, anyone holding a *Account can change it; in Rust, the right to change something is visible in the code.

Side by side: implicit interfaces become explicit traits

Section titled “Side by side: implicit interfaces become explicit traits”

In Go, a type satisfies an interface just by having the right methods:

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// No "implements" anywhere: having the methods is enough.
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string { return "circle" }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Name() string { return "rect" }
func describe[T Shape](s T) {
fmt.Printf("%s: %.2f\n", s.Name(), s.Area())
}
func main() {
describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("total area: %.2f\n", total)
}

In Rust, you write the connection down with impl Shape for Circle:

use std::f64::consts::PI;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
// This line is the part Go leaves out.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"circle"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"rect"
}
}
// Generic, like Go's describe[T Shape](s T).
fn describe<T: Shape>(s: &T) {
println!("{}: {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Like Go's []Shape: different types behind one trait.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("total area: {total:.2}");
}

Output:

circle: 3.14
total area: 9.14
▶ Try it Yourself

Being explicit costs one line per type, and buys you some things Go can’t do. A trait can have default methods, so types get shared behavior for free. You can implement your own trait for types you didn’t write, even i32 or String (Go only lets you add methods to types in your own package). And the generic describe is compiled once per concrete type, so the call has no runtime lookup. Box<dyn Shape> is the Go-interface style: one value that points to “some type that implements Shape”, with the method looked up at runtime. See Traits and Trait Objects.

In Go, a pointer that might be missing is nil, and you have to remember to check:

type User struct {
Name string
Manager *User // nil means "no manager"
}
func findUser(users []*User, name string) *User {
for _, u := range users {
if u.Name == name {
return u
}
}
return nil
}
func main() {
ada := &User{Name: "Ada"}
users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} {
u := findUser(users, name)
if u == nil {
fmt.Println(name + ": no such user")
continue
}
boss := "nobody"
if u.Manager != nil {
boss = u.Manager.Name
}
fmt.Println(name, "reports to", boss)
}
}

In Rust, “might be missing” is part of the type. Option<&User> is either Some(user) or None, and the compiler won’t let you use the user until you’ve handled None:

struct User {
name: String,
manager: Option<String>, // Go: Manager *User, which may be nil
}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
fn main() {
let users = vec![
User { name: "Ada".to_string(), manager: None },
User { name: "Bob".to_string(), manager: Some("Ada".to_string()) },
];
for name in ["Bob", "Ada", "Eve"] {
match find_user(&users, name) {
Some(user) => {
let boss = user.manager.as_deref().unwrap_or("nobody");
println!("{name} reports to {boss}");
}
None => println!("{name}: no such user"),
}
}
}

Output:

Bob reports to Ada
Ada reports to nobody
Eve: no such user
▶ Try it Yourself

The same idea replaces Go’s comma-ok lookups. if email, ok := emails[name]; ok { ... } becomes if let Some(email) = emails.get(name) { ... }. One thing to watch: in Go, emails["missing"] quietly gives you the zero value "". In Rust, emails["missing"] panics, so use .get() whenever the key might not be there. The 'a in find_user is a lifetime: it tells the compiler that the returned &User points into users. You’ll meet those soon.

Side by side: if err != nil becomes Result and ?

Section titled “Side by side: if err != nil becomes Result and ?”

Go’s error handling is famous for its three-line pattern:

func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid port %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %d out of range", n)
}
return n, nil
}
func address(host, port string) (string, error) {
p, err := parsePort(port)
if err != nil {
return "", err
}
return fmt.Sprintf("%s:%d", host, p), nil
}
func main() {
for _, port := range []string{"8080", "abc", "70000", "0"} {
addr, err := address("localhost", port)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("listening on", addr)
}
}

Rust keeps the idea (errors are values you return) but folds the pattern into one character, ?:

fn parse_port(s: &str) -> Result<u16, String> {
// A u16 can't hold 70000, so parse() catches "out of range" for us.
let port: u16 = s.parse().map_err(|e| format!("invalid port {s:?}: {e}"))?;
if port == 0 {
return Err("port 0 is reserved".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?` means: if this is an error, return it right now.
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
for port in ["8080", "abc", "70000", "0"] {
match address("localhost", port) {
Ok(addr) => println!("listening on {addr}"),
Err(e) => println!("error: {e}"),
}
}
}

Output:

listening on localhost:8080
error: invalid port "abc": invalid digit found in string
error: invalid port "70000": number too large to fit in target type
error: port 0 is reserved
▶ Try it Yourself

There’s a deeper difference than saving lines. In Go, n, err := strconv.Atoi(s) gives you both values, and nothing stops you from using n when err isn’t nil. A Result is either the value or the error, so you can’t reach the value without dealing with the error first. And if you ignore a Result completely, the compiler warns you.

For real projects, String errors are replaced with something richer. An error enum (often written with the thiserror crate) plays the role of sentinel errors and errors.Is: callers match on the variant. The anyhow crate gives you .context("loading config")?, which is Rust’s fmt.Errorf("loading config: %w", err). See Custom Errors and anyhow & thiserror.

Side by side: goroutines and channels become threads, mpsc, and tasks

Section titled “Side by side: goroutines and channels become threads, mpsc, and tasks”

Here is the classic Go fan-in: start some goroutines, send results down a channel, close it when they’re done:

func main() {
results := make(chan int)
var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
total += r
}
fmt.Println("total:", total)
}

The Rust version with threads and a standard-library channel is a little shorter:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] {
let tx = tx.clone(); // each thread gets its own sender
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// Drop our own sender. When every sender is gone, the channel is
// closed and the loop below ends: no WaitGroup needed.
drop(tx);
let total: i32 = rx.iter().sum();
println!("total: {total}");
}

Output:

total: 14
▶ Try it Yourself

A few things map differently. mpsc means “multi-producer, single-consumer”: you can clone the sender, but not the receiver (for Go-style many-readers channels, use crossbeam-channel). mpsc::channel() is unbounded, which Go doesn’t have; mpsc::sync_channel(n) is Go’s make(chan T, n), and sync_channel(0) is an unbuffered channel. Nobody calls close: the channel closes when the last sender is dropped. move hands each thread its own copy of n and its own tx, which is how Rust makes sure threads never share data unsafely. See Channels and Concurrency.

thread::spawn starts a real OS thread, which is heavier than a goroutine. That’s fine for a handful of workers. For thousands of concurrent connections, Rust uses async tasks on a runtime like tokio, which is much closer to goroutines:

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// Like `go func() { ... }()`, but you get a handle back.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("total: {total}");
}

Output:

total: 14
▶ Try it Yourself

Being honest: this is one place where Go is simpler. In Go, every function can block and the runtime handles it. In Rust, you mark functions async, call them with .await, and pick a runtime. The payoff is that the compiler refuses to build code where two threads could change the same data at once, so go test -race has nothing to find. See Async/Await, Async in Depth, and Shared State for Arc<Mutex<T>>, Rust’s version of a struct with a sync.Mutex next to its data.

In Go, you schedule cleanup with defer, and deferred calls run in reverse order when the function returns:

func process() {
fmt.Println("open file")
defer fmt.Println("close file")
fmt.Println("lock mutex")
defer fmt.Println("unlock mutex")
fmt.Println("working")
}
func main() {
process()
fmt.Println("done")
}

In Rust, cleanup belongs to the value itself. When a value goes out of scope, its Drop implementation runs, and values are dropped in reverse order, just like your defers:

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("open file");
let _file = Guard("close file");
println!("lock mutex");
let _lock = Guard("unlock mutex");
println!("working");
} // _lock is dropped here, then _file
fn main() {
process();
println!("done");
}

Output:

open file
lock mutex
working
unlock mutex
close file
done
▶ Try it Yourself

In real code you rarely write Drop yourself: File closes itself, a MutexGuard unlocks itself, a TcpStream disconnects itself. There’s nothing to forget. Two differences from defer are worth knowing. Drop runs at the end of the block, not the function, so the Go trap of defer piling up inside a loop doesn’t exist: each loop iteration cleans up after itself. And watch out for let _ = Guard(...): the bare _ doesn’t keep the value at all, so it’s dropped immediately. Use a name like _lock instead.

Things that will trip you up as a Go developer

Section titled “Things that will trip you up as a Go developer”

Everyone coming from Go hits the same bumps. Knowing them in advance helps.

  • Passing a value can move it. In Go, passing a slice to a function copies a small header and both sides keep using it. In Rust, passing a Vec moves it into the function, and you can’t use it afterwards:

    fn total(prices: Vec<u32>) -> u32 {
    prices.iter().sum()
    }
    fn main() {
    let prices = vec![3, 4, 5];
    let sum = total(prices);
    println!("{sum} from {} items", prices.len());
    }
    error[E0382]: borrow of moved value: `prices`
    --> src/main.rs:8:37
    |
    6 | let prices = vec![3, 4, 5];
    | ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    The fix is the Go habit in Rust form: make the function borrow a slice, fn total(prices: &[u32]), and call it with total(&prices). Read Ownership first; it explains this rule. E0382 covers this error in detail.

  • The borrow checker. You can have many readers (&T) or one writer (&mut T) at a time, never both. Go code where many structs hold pointers to one shared struct and change it needs a different design in Rust, or smart pointers like Rc<RefCell<T>> or Arc<Mutex<T>>. Expect a few frustrating weeks. It gets much easier.

  • No zero values. let x: i32; followed by println!("{x}") is a compile error, and every struct field must be set when you create a struct. If you want Go-style defaults, derive Default and write Config { port: 8080, ..Default::default() }.

  • Implementing a trait is explicit. Having an area method doesn’t make your type a Shape. You need impl Shape for Circle. The upside: you can see every implementation by searching for it.

  • String vs &str. Go has one string type. Rust has two: String owns its text, &str borrows it. Functions usually take &str, structs usually store String. And you can’t index a string with s[0] like in Go; use s.chars() or s.as_bytes()[0]. See Strings.

  • Unused things are warnings, not errors. Go refuses to compile with an unused import or variable. Rust only warns, and you can prefix a name with _ to say “unused on purpose”.

  • Integer overflow. Go wraps silently. Rust panics in debug builds and wraps in release builds, so pick checked_add, wrapping_add, or saturating_add when overflow is possible. Integer literals default to i32, and indexes are usize. See Integer Overflow.

  • A smaller standard library. No HTTP server, no JSON, no async runtime in std. You’ll cargo add them. That feels odd at first, but the popular crates are stable and well documented.

  • Async needs a runtime and spreads. An async fn does nothing until it’s awaited inside a runtime like tokio, and only async code can .await. Go has no such split. Start with plain threads; reach for async when you need many concurrent connections.

  • Compile times. Go was built to compile very fast. Rust compiles more slowly, especially clean builds with many crates. Use cargo check while you edit; it skips code generation and is much quicker.

  • Cross-compiling takes a little setup. GOOS=linux go build just works. In Rust, you add the target with rustup and often need a linker for it too. Tools like cross handle that for you.

Both are good choices, and many teams use both. Here is a fair rule of thumb.

Pick Go when you want to ship network services and internal tools quickly, your team values code that anyone can read after a week, fast builds matter a lot, or you’re working in the cloud-native world (Kubernetes, Docker, Terraform) where Go is the common language. Go’s standard library and its simple concurrency make many server programs easy to write.

Pick Rust when you need predictable latency with no garbage-collection pauses, low memory use, or every bit of CPU (parsers, databases, game engines, video, compression). Rust is also the better fit for code without an operating system, for WebAssembly, and for libraries you want to call from other languages. And it’s worth it when a crash from a data race or a missing nil check is expensive. Discord wrote a well-known post about moving one service from Go to Rust because of latency spikes caused by garbage collection.

The cost of Rust is learning time and slower builds. The cost of Go is a garbage collector and bugs that show up at runtime instead of compile time. Which cost hurts more depends on your project.

You don’t need to read this site in order. As a Go developer, this route skips what you already know and spends time where Rust is different:

  1. Get Started and Rust Tools & Setup: install Rust, set up rust-analyzer, and run cargo new.
  2. Variables and Data Types: skim these, mostly for let mut and the number types.
  3. Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
  4. Strings: String vs &str.
  5. Structs and Enums, then match and Pattern Matching. Rust enums with data replace many Go type switches.
  6. Option and Result, then Custom Errors and anyhow & thiserror.
  7. Traits, Generics, Common Traits, and Trait Objects.
  8. Closures and Iterators: Rust leans on iterator chains far more than Go does.
  9. Vectors, HashMaps, and HashSet.
  10. Modules, Cargo & Crates, and Testing.
  11. Lifetimes and Smart Pointers.
  12. Concurrency, Channels, Shared State, then Async/Await.
  13. Serde & JSON and Logging, then build the Web API project.

Both exercises give you a Go snippet. Port it to Rust.

This Go program counts words and prints them in alphabetical order. Port it to Rust. Hint: use a HashMap<&str, u32> and *counts.entry(word).or_insert(0) += 1 for Go’s counts[w]++. text.split_whitespace() is strings.Fields.

func main() {
text := "the cat and the dog and the bird"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}
Show solution
use std::collections::HashMap;
fn main() {
let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut keys: Vec<&str> = counts.keys().copied().collect();
keys.sort();
for key in keys {
println!("{key}: {}", counts[key]);
}
}

Output:

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Try it Yourself

Go has no zero value to lean on here, so entry(word).or_insert(0) says “start at 0 if the word is new”. Indexing with counts[key] is safe because every key came from the map. Even shorter: use a BTreeMap instead of a HashMap, and the keys come out sorted, so you can drop the keys vector.

This Go code returns a sentinel error and checks it with errors.Is. Port it so that withdraw returns a Result<(), WithdrawError>, where WithdrawError is an enum with two variants. Hint: implement fmt::Display for the enum so you can print it, and match on the variant where Go uses errors.Is.

var ErrInsufficientFunds = errors.New("insufficient funds")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error {
if n <= 0 {
return fmt.Errorf("invalid amount %d", n)
}
if n > a.Balance {
return ErrInsufficientFunds
}
a.Balance -= n
return nil
}
func main() {
acc := &Account{Balance: 100}
for _, n := range []int{30, 0, 500} {
if err := acc.Withdraw(n); err != nil {
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("declined:", err)
} else {
fmt.Println("error:", err)
}
continue
}
fmt.Println("ok, balance", acc.Balance)
}
}
Show solution
use std::fmt;
enum WithdrawError {
InvalidAmount(i64),
InsufficientFunds,
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::InvalidAmount(n) => write!(f, "invalid amount {n}"),
WithdrawError::InsufficientFunds => write!(f, "insufficient funds"),
}
}
}
struct Account {
balance: i64,
}
impl Account {
fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> {
if n <= 0 {
return Err(WithdrawError::InvalidAmount(n));
}
if n > self.balance {
return Err(WithdrawError::InsufficientFunds);
}
self.balance -= n;
Ok(())
}
}
fn main() {
let mut acc = Account { balance: 100 };
for n in [30, 0, 500] {
match acc.withdraw(n) {
Ok(()) => println!("ok, balance {}", acc.balance),
Err(e @ WithdrawError::InsufficientFunds) => println!("declined: {e}"),
Err(e) => println!("error: {e}"),
}
}
}

Output:

ok, balance 70
error: invalid amount 0
declined: insufficient funds
▶ Try it Yourself

The sentinel error and the fmt.Errorf error become two variants of one enum, and errors.Is becomes a match arm. e @ WithdrawError::InsufficientFunds matches the variant and also gives you the whole value as e to print. A nice bonus: if you add a third variant later, the compiler shows you every match that needs updating. The thiserror crate can write the Display impl for you.

  • Much of Go carries over: structs with methods, no inheritance, errors as values, one standard formatter, and one tool for building and testing.
  • The big shifts are ownership instead of a garbage collector, Option instead of nil, and Result + ? instead of if err != nil.
  • Interfaces become traits, and implementing one is explicit (impl Trait for Type). defer becomes automatic Drop at the end of a scope.
  • Goroutines and channels become thread::spawn + mpsc for a few workers, or tokio tasks for many; the compiler rules out data races.
  • Cargo replaces go build, go test, and go mod; clippy and rustfmt replace go vet and gofmt. For Gin look at axum or actix-web, for GORM look at SeaORM or Diesel, for pgx and sqlc look at sqlx, and for cobra look at clap.

Quick check

1. In Go, a type satisfies an interface just by having the right methods. How does a Rust type implement a trait?

2. What does ? do after a call that returns a Result?

3. With std::sync::mpsc, when does for msg in rx (or rx.iter()) stop?

4. Your Go team builds its HTTP APIs with Gin. Which Rust crate is the closest match?

Score: 0 / 4