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.