Go to Rust Series: ← Setting Up Rust | Series Overview | Ownership and Borrowing →
The First Encounter
Week 1 with Go: I shipped features.
Week 1 with Rust: I fought the compiler.
This isn’t a bug—it’s Rust’s design. The compiler is strict, verbose, and often frustrating. But after fighting it for weeks, I realized: it’s actually trying to help me.
Compiler Philosophy
Go: Trust the Developer
Go’s compiler does basic checks and gets out of your way:
func main() {
var x *int
fmt.Println(*x) // Compiles fine!
}
Compiler output:
✓ Build successful
Runtime output:
panic: runtime error: invalid memory address or nil pointer dereference
The compiler trusts you to handle nil correctly. If you don’t, it crashes at runtime.
Rust: Prove Safety at Compile Time
The equivalent Rust code:
fn main() {
let x: Option<i32> = None;
println!("{}", x); // ERROR!
}
Compiler output:
error[E0277]: `Option<i32>` doesn't implement `std::fmt::Display`
--> src/main.rs:3:20
|
3 | println!("{}", x);
| ^ `Option<i32>` cannot be formatted with the default formatter
The compiler refuses to compile unsafe code. You must explicitly handle the None case:
fn main() {
let x: Option<i32> = None;
match x {
Some(val) => println!("{}", val),
None => println!("No value"),
}
}
Philosophy difference:
- Go: “Compile it, let the developer handle it”
- Rust: “Don’t compile it unless it’s safe”
Error Message Quality
Go: Concise and Cryptic
Example 1: Type mismatch
package main
func main() {
var x int32 = 42
var y int64 = x
}
Error:
cannot use x (type int32) as type int64 in assignment
Clear, but no suggestions on how to fix it.
Example 2: Unused variable
func main() {
x := 42
}
Error:
x declared and not used
Concise. You figure out the fix.
Rust: Verbose but Helpful
Example 1: Type mismatch
fn main() {
let x: i32 = 42;
let y: i64 = x;
}
Error:
error[E0308]: mismatched types
--> src/main.rs:3:18
|
3 | let y: i64 = x;
| --- ^ expected `i64`, found `i32`
| |
| expected due to this
|
help: you can convert an `i32` to an `i64`
|
3 | let y: i64 = x.into();
| +++++++
Not only does it explain the error, it suggests the fix!
Example 2: Unused variable
fn main() {
let x = 42;
}
Error:
warning: unused variable: `x`
--> src/main.rs:2:9
|
2 | let x = 42;
| ^ help: if this is intentional, prefix it with an underscore: `_x`
It tells you exactly how to silence the warning.
Winner: Rust - Error messages are teaching tools.
The Borrow Checker: Go’s Biggest Difference
Go doesn’t have a borrow checker. Rust does. This is the #1 source of frustration for Go developers.
Go: Pass References Freely
package main
import "fmt"
func modify(s *[]int) {
*s = append(*s, 999)
}
func read(s *[]int) {
fmt.Println((*s)[0])
}
func main() {
nums := []int{1, 2, 3}
modify(&nums) // Modify
read(&nums) // Read
fmt.Println(nums) // Still accessible
}
Output:
1
[1 2 3 999]
Works fine. Go’s garbage collector handles everything.
Rust: Borrowing Rules Enforced
The equivalent Rust code:
fn modify(s: &mut Vec<i32>) {
s.push(999);
}
fn read(s: &Vec<i32>) {
println!("{}", s[0]);
}
fn main() {
let mut nums = vec![1, 2, 3];
modify(&mut nums); // Mutable borrow
read(&nums); // Immutable borrow
println!("{:?}", nums); // OK - previous borrows ended
}
This works! But try this:
fn main() {
let mut nums = vec![1, 2, 3];
let r1 = &nums; // Immutable borrow
let r2 = &nums; // Another immutable borrow (OK)
let r3 = &mut nums; // Mutable borrow (ERROR!)
println!("{}, {}", r1, r2);
}
Error:
error[E0502]: cannot borrow `nums` as mutable because it is also borrowed as immutable
--> src/main.rs:5:14
|
3 | let r1 = &nums;
| ----- immutable borrow occurs here
4 | let r2 = &nums;
5 | let r3 = &mut nums;
| ^^^^^^^^^ mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- immutable borrow later used here
The rule: You can have EITHER:
- Multiple immutable references (
&T) - ONE mutable reference (
&mut T)
But not both at the same time.
Why Go Doesn’t Need This
Go has garbage collection:
func dangling() *int {
x := 42
return &x // OK! GC keeps x alive
}
The GC tracks the reference and keeps x alive even after the function returns.
Rust doesn’t have GC:
fn dangling() -> &i32 {
let x = 42;
&x // ERROR! x is dropped at end of function
}
Error:
error[E0106]: missing lifetime specifier
--> src/main.rs:1:18
|
1 | fn dangling() -> &i32 {
| ^ expected named lifetime parameter
Rust refuses to compile code that would create dangling pointers.
Common Errors: Go vs Rust
1. Nil Pointer Dereference
Go:
func getUser() *User {
return nil // Common pattern
}
func main() {
user := getUser()
fmt.Println(user.Name) // PANIC at runtime!
}
Runtime:
panic: runtime error: invalid memory address or nil pointer dereference
Rust:
fn get_user() -> Option<User> {
None
}
fn main() {
let user = get_user();
println!("{}", user.name); // ERROR at compile time!
}
Compile time:
error[E0609]: no field `name` on type `Option<User>`
Rust forces you to handle the None case.
2. Data Races
Go:
func main() {
counter := 0
for i := 0; i < 1000; i++ {
go func() {
counter++ // DATA RACE!
}()
}
time.Sleep(time.Second)
fmt.Println(counter) // Undefined behavior
}
Compiles and runs, but behavior is undefined. Might print 1000, might print 723, might crash.
Rust:
fn main() {
let mut counter = 0;
for i in 0..1000 {
std::thread::spawn(|| {
counter += 1; // ERROR!
});
}
}
Error:
error[E0373]: closure may outlive the current function
error[E0499]: cannot borrow `counter` as mutable more than once at a time
Rust refuses to compile code with data races. You must use synchronization:
use std::sync::{Arc, Mutex};
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..1000 {
let counter_clone = Arc::clone(&counter);
let handle = std::thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // Always 1000
}
More verbose, but guaranteed race-free.
3. Use After Move
Go:
type BigData struct {
data [1000000]int
}
func main() {
x := BigData{}
y := x // Copied (expensive!)
_ = x // Still usable
_ = y // Also usable
}
Go copies the entire struct. Both x and y are usable.
Rust:
struct BigData {
data: Vec<i32>,
}
fn main() {
let x = BigData { data: vec![0; 1000000] };
let y = x; // MOVED, not copied
println!("{:?}", x); // ERROR! x was moved
}
Error:
error[E0382]: borrow of moved value: `x`
--> src/main.rs:8:22
|
6 | let x = BigData { data: vec![0; 1000000] };
| - move occurs because `x` has type `BigData`
7 | let y = x;
| - value moved here
8 | println!("{:?}", x);
| ^ value borrowed here after move
Rust moves by default (cheap), and prevents use-after-move.
Learning to Love the Compiler
After weeks of fighting, I started to appreciate Rust’s compiler:
Example 1: Iterator Invalidation
Go (compiles, but buggy):
func removeEvens(nums []int) []int {
for i, num := range nums {
if num%2 == 0 {
nums = append(nums[:i], nums[i+1:]...) // BUG!
}
}
return nums
}
This compiles but has a bug (skips elements). You’d only find it during testing or production.
Rust (won’t compile if wrong):
fn remove_evens(nums: &mut Vec<i32>) {
nums.retain(|&x| x % 2 != 0); // Idiomatic solution
}
Or if you try the buggy Go approach:
fn remove_evens(nums: &mut Vec<i32>) {
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
nums.remove(i); // Compiles, but clippy warns!
}
}
}
Clippy warning:
warning: you are using `remove` in a loop
help: consider using `retain`: `nums.retain(|&x| x % 2 != 0)`
Example 2: Forgetting to Handle Errors
Go:
file, _ := os.Open("data.txt") // Ignoring error (bad!)
defer file.Close()
Compiles fine. Crashes at runtime if file doesn’t exist.
Rust:
let file = std::fs::File::open("data.txt"); // ERROR!
Error:
error: unused `Result` that must be used
help: use `let _ = ...` to ignore the resulting value
Rust forces you to handle the Result:
let file = std::fs::File::open("data.txt")?; // Propagate error
// or
let file = std::fs::File::open("data.txt")
.expect("Failed to open file"); // Panic with message
When Go’s Permissiveness Hurts
Real-world bug I wrote in Go:
func processUsers(db *sql.DB) error {
rows, err := db.Query("SELECT id, name FROM users")
if err != nil {
return err
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
rows.Scan(&user.ID, &user.Name) // Forgot to check error!
users = append(users, user)
}
// Process users...
return nil
}
This compiles. The bug (not checking Scan error) caused silent data corruption in production.
Rust equivalent:
fn process_users(conn: &Connection) -> Result<(), Error> {
let mut stmt = conn.prepare("SELECT id, name FROM users")?;
let users: Result<Vec<User>, _> = stmt
.query_map([], |row| {
Ok(User {
id: row.get(0)?, // Error automatically propagated
name: row.get(1)?,
})
})?
.collect(); // Collect Results
let users = users?; // Unwrap or propagate error
// Process users...
Ok(())
}
Can’t forget error handling—the ? operator forces it.
Compilation Speed: The Trade-Off
Go: Fast Iteration
$ time go build
real 0m0.234s
$ # Make change
$ time go build
real 0m0.156s
Instant feedback loop.
Rust: Slower Iteration
$ time cargo build
real 0m12.456s
$ # Make small change
$ time cargo build
real 0m1.234s
First build is slow. Incremental builds are better, but still slower than Go.
The trade-off: Rust’s compiler does more work upfront (borrow checking, optimization) to prevent runtime errors.
Helpful vs Strict
Go Compiler Messages: Minimal
./main.go:10:2: undefined: foo
./main.go:15:9: cannot use x (type int) as type string
./main.go:20:1: missing return
You figure out the fix.
Rust Compiler Messages: Teaching
error[E0308]: mismatched types
--> src/main.rs:10:5
|
10 | foo()
| ^^^^^ expected `i32`, found `&str`
|
= note: expected type `i32`
found type `&str`
help: try using a conversion method
|
10 | foo().parse::<i32>().unwrap()
|
It teaches you how to fix it.
The Verdict
| Aspect | Go | Rust |
|---|---|---|
| Compile speed | Very fast | Slower |
| Error messages | Concise | Verbose & helpful |
| Runtime safety | Trust developer | Enforce at compile time |
| Learning curve | Gentle | Steep |
| Bug prevention | Linter + discipline | Enforced by compiler |
| Iteration speed | Faster | Slower |
Go’s approach: “Trust developers, fail fast at runtime if needed.”
Rust’s approach: “Prevent entire classes of bugs at compile time.”
Conclusion
For Go developers learning Rust: The compiler will frustrate you. It will reject code that “obviously works.” You’ll spend hours fighting borrow checker errors.
But stick with it. The compiler isn’t your enemy—it’s a strict teacher preventing bugs before they reach production.
After 3 months with Rust, I realized:
- The compiler catches bugs I didn’t know existed
- I spend less time debugging runtime issues
- I trust my code more after it compiles
But I also realized:
- Go’s faster iteration is valuable for rapid prototyping
- Not every project needs Rust’s guarantees
- Sometimes “good enough” is better than “provably correct”
Next up: Deep dive into Ownership and Borrowing—the concept Go doesn’t have that changes everything.
Go to Rust Series: ← Setting Up Rust | Series Overview | Ownership and Borrowing →
Compiler verdict: Rust’s compiler is annoying, helpful, and ultimately right. Go’s compiler is fast and gets out of your way. Pick your trade-off.