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.
Language concepts: Go to Rust
Section titled “Language concepts: Go to Rust”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 |
Collections
Section titled “Collections”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.
Tooling: go to cargo
Section titled “Tooling: go to cargo”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.
Web and networking
Section titled “Web and networking”| 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 |
CLI, config, logging, and everyday tools
Section titled “CLI, config, logging, and everyday tools”| 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 |
Desktop, games, and embedded
Section titled “Desktop, games, and embedded”| 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: 150balance is 150The 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.14total area: 9.14Being 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.
Side by side: nil becomes Option
Section titled “Side by side: nil becomes Option”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 AdaAda reports to nobodyEve: no such userThe 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:8080error: invalid port "abc": invalid digit found in stringerror: invalid port "70000": number too large to fit in target typeerror: port 0 is reservedThere’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: 14A 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: 14Being 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.
Side by side: defer becomes Drop
Section titled “Side by side: defer becomes Drop”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 filelock mutexworkingunlock mutexclose filedoneIn 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
Vecmoves 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` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after moveThe fix is the Go habit in Rust form: make the function borrow a slice,
fn total(prices: &[u32]), and call it withtotal(&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 likeRc<RefCell<T>>orArc<Mutex<T>>. Expect a few frustrating weeks. It gets much easier. -
No zero values.
let x: i32;followed byprintln!("{x}")is a compile error, and every struct field must be set when you create a struct. If you want Go-style defaults, deriveDefaultand writeConfig { port: 8080, ..Default::default() }. -
Implementing a trait is explicit. Having an
areamethod doesn’t make your type aShape. You needimpl Shape for Circle. The upside: you can see every implementation by searching for it. -
Stringvs&str. Go has onestringtype. Rust has two:Stringowns its text,&strborrows it. Functions usually take&str, structs usually storeString. And you can’t index a string withs[0]like in Go; uses.chars()ors.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, orsaturating_addwhen overflow is possible. Integer literals default toi32, and indexes areusize. See Integer Overflow. -
A smaller standard library. No HTTP server, no JSON, no async runtime in
std. You’llcargo addthem. That feels odd at first, but the popular crates are stable and well documented. -
Async needs a runtime and spreads. An
async fndoes 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 checkwhile you edit; it skips code generation and is much quicker. -
Cross-compiling takes a little setup.
GOOS=linux go buildjust works. In Rust, you add the target with rustup and often need a linker for it too. Tools likecrosshandle that for you.
Go or Rust? When to pick each
Section titled “Go or Rust? When to pick each”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.
A learning path for Go developers
Section titled “A learning path for Go developers”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:
- Get Started and Rust Tools & Setup: install Rust, set up rust-analyzer, and run
cargo new. - Variables and Data Types: skim these, mostly for
let mutand the number types. - Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
- Strings:
Stringvs&str. - Structs and Enums, then match and Pattern Matching. Rust enums with data replace many Go type switches.
- Option and Result, then Custom Errors and anyhow & thiserror.
- Traits, Generics, Common Traits, and Trait Objects.
- Closures and Iterators: Rust leans on iterator chains far more than Go does.
- Vectors, HashMaps, and HashSet.
- Modules, Cargo & Crates, and Testing.
- Lifetimes and Smart Pointers.
- Concurrency, Channels, Shared State, then Async/Await.
- Serde & JSON and Logging, then build the Web API project.
Practice
Section titled “Practice”Both exercises give you a Go snippet. Port it to Rust.
1. Port a word counter
Section titled “1. Port a word counter”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: 2bird: 1cat: 1dog: 1the: 3Go 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.
2. Port a sentinel error
Section titled “2. Port a sentinel error”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 70error: invalid amount 0declined: insufficient fundsThe 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,
Optioninstead ofnil, andResult+?instead ofif err != nil. - Interfaces become traits, and implementing one is explicit (
impl Trait for Type).deferbecomes automaticDropat the end of a scope. - Goroutines and channels become
thread::spawn+mpscfor a few workers, or tokio tasks for many; the compiler rules out data races. - Cargo replaces
go build,go test, andgo mod; clippy and rustfmt replacego vetandgofmt. 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
Section titled “Quick check”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
Read more
Section titled “Read more”- The Rust Programming Language: the official book. Chapters 4 (ownership), 9 (errors), 10 (generics and traits), and 16 (concurrency) matter most for Go developers.
- Tokio tutorial: the async runtime most Rust servers use, the closest thing to Go’s goroutine scheduler.
- axum documentation: the web framework to look at first if you come from Gin, Echo, or Chi.
- Why Discord is switching from Go to Rust: a real team’s story of moving a latency-sensitive service from Go to Rust.
- Are We Web Yet?: a community overview of Rust’s web libraries and frameworks.