Go to Rust Series: ← The Rust Compiler | Series Overview | No Garbage Collector →


The Core Difference

Ownership is Rust’s most important concept—and the one Go completely lacks.

In Go, the garbage collector handles memory. In Rust, the ownership system handles memory at compile time, with zero runtime cost.

Go’s Approach: Share and GC

Go:

package main

import "fmt"

func main() {
    data := []int{1, 2, 3}

    // Pass to multiple functions
    printData(data)
    modifyData(data)
    printData(data)

    // Still usable
    fmt.Println(data)
}

func printData(d []int) {
    fmt.Println(d)
}

func modifyData(d []int) {
    d[0] = 999  // Modifies original
}

Output:

[1 2 3]
[999 2 3]
[999 2 3]

Data is shared. The GC tracks references and cleans up when no longer needed.

Rust’s Approach: Ownership Rules

Rust has three fundamental ownership rules:

  1. Each value has one owner
  2. When the owner goes out of scope, the value is dropped
  3. Values can be borrowed (temporarily) but not owned by multiple places

Rust:

fn main() {
    let data = vec![1, 2, 3];

    print_data(&data);     // Borrow (immutable)
    modify_data(&mut data); // ERROR! data is not mutable
    print_data(&data);
}

fn print_data(d: &Vec<i32>) {
    println!("{:?}", d);
}

fn modify_data(d: &mut Vec<i32>) {
    d[0] = 999;
}

Must make data mutable:

fn main() {
    let mut data = vec![1, 2, 3];  // Now mutable

    print_data(&data);
    modify_data(&mut data);
    print_data(&data);

    println!("{:?}", data);
}

Move Semantics

Go: Copy Semantics (Usually)

Go:

func takeOwnership(s string) {
    fmt.Println(s)
}

func main() {
    text := "Hello"
    takeOwnership(text)
    fmt.Println(text)  // Still works!
}

Output:

Hello
Hello

Strings in Go are immutable and cheap to copy (just a pointer and length).

With slices:

func takeOwnership(s []int) {
    s[0] = 999  // Modifies original!
}

func main() {
    nums := []int{1, 2, 3}
    takeOwnership(nums)
    fmt.Println(nums)  // [999 2 3]
}

Slices are references under the hood, so modifications affect the original.

Rust: Move Semantics (By Default)

Rust:

fn take_ownership(s: String) {
    println!("{}", s);
}  // s is dropped here

fn main() {
    let text = String::from("Hello");
    take_ownership(text);  // text is MOVED
    println!("{}", text);  // ERROR! text was moved
}

Error:

error[E0382]: borrow of moved value: `text`
  --> src/main.rs:8:20
   |
6  |     let text = String::from("Hello");
   |         ---- move occurs because `text` has type `String`
7  |     take_ownership(text);
   |                    ---- value moved here
8  |     println!("{}", text);
   |                    ^^^^ value borrowed here after move

Solutions:

1. Clone (explicit copy):

fn main() {
    let text = String::from("Hello");
    take_ownership(text.clone());  // Clone before moving
    println!("{}", text);  // Works!
}

2. Borrow instead of move:

fn borrow_string(s: &String) {
    println!("{}", s);
}  // Borrow ends here

fn main() {
    let text = String::from("Hello");
    borrow_string(&text);  // Borrow, don't move
    println!("{}", text);  // Works!
}

Borrowing: Immutable References

Go:

func read1(s *string) {
    fmt.Println(*s)
}

func read2(s *string) {
    fmt.Println(*s)
}

func main() {
    text := "Hello"
    read1(&text)
    read2(&text)  // Multiple references OK
}

Rust:

fn read1(s: &String) {
    println!("{}", s);
}

fn read2(s: &String) {
    println!("{}", s);
}

fn main() {
    let text = String::from("Hello");
    read1(&text);
    read2(&text);  // Multiple immutable borrows OK
}

Rule: You can have unlimited immutable borrows (&T) simultaneously.

Mutable Borrowing: One at a Time

Go:

func modify1(nums *[]int) {
    (*nums)[0] = 1
}

func modify2(nums *[]int) {
    (*nums)[0] = 2
}

func main() {
    nums := []int{0, 0, 0}
    ptr1 := &nums
    ptr2 := &nums  // Multiple mutable refs OK

    modify1(ptr1)
    modify2(ptr2)
    fmt.Println(nums)  // [2 0 0]
}

Works fine. But can lead to data races with goroutines.

Rust:

fn modify1(nums: &mut Vec<i32>) {
    nums[0] = 1;
}

fn modify2(nums: &mut Vec<i32>) {
    nums[0] = 2;
}

fn main() {
    let mut nums = vec![0, 0, 0];
    let r1 = &mut nums;
    let r2 = &mut nums;  // ERROR! Only one mutable borrow allowed

    modify1(r1);
    modify2(r2);
}

Error:

error[E0499]: cannot borrow `nums` as mutable more than once at a time

Rule: You can have only ONE mutable borrow (&mut T) at a time.

Correct version:

fn main() {
    let mut nums = vec![0, 0, 0];

    modify1(&mut nums);  // Borrow ends after this line
    modify2(&mut nums);  // New borrow starts here

    println!("{:?}", nums);  // [2 0 0]
}

No Simultaneous Read/Write

Go (compiles, but dangerous):

func main() {
    data := []int{1, 2, 3}
    reader := &data
    writer := &data

    // Simultaneous read and write (data race!)
    go func() {
        for {
            fmt.Println((*reader)[0])
        }
    }()

    go func() {
        for {
            (*writer)[0] = 999
        }
    }()

    time.Sleep(time.Second)
}

Compiles. Results in data race at runtime.

Rust (won’t compile):

fn main() {
    let mut data = vec![1, 2, 3];
    let reader = &data;      // Immutable borrow
    let writer = &mut data;  // ERROR! Can't have both

    println!("{}", reader[0]);
    writer[0] = 999;
}

Error:

error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable

Rule: You can have EITHER:

  • Many immutable references (&T), OR
  • One mutable reference (&mut T)

But not both simultaneously.

Scope and Lifetimes

Go:

func dangling() *int {
    x := 42
    return &x  // OK! GC keeps x alive
}

func main() {
    ptr := dangling()
    fmt.Println(*ptr)  // 42
}

GC ensures x outlives the pointer.

Rust:

fn dangling() -> &i32 {
    let x = 42;
    &x  // ERROR! x is dropped at end of function
}

Error:

error[E0106]: missing lifetime specifier

Rust prevents dangling references at compile time.

Correct version:

fn not_dangling() -> i32 {
    42  // Return value, not reference
}

fn main() {
    let val = not_dangling();
    println!("{}", val);
}

Real-World Example: Building a Cache

Go: Simple but Requires Sync

Go:

package main

import (
    "fmt"
    "sync"
)

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func NewCache() *Cache {
    return &Cache{
        data: make(map[string]string),
    }
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

func main() {
    cache := NewCache()
    cache.Set("name", "Gopher")
    val, _ := cache.Get("name")
    fmt.Println(val)
}

Manual locking required. Easy to forget.

Rust: Safety Built-In

Rust:

use std::collections::HashMap;
use std::sync::{Arc, RwLock};

struct Cache {
    data: RwLock<HashMap<String, String>>,
}

impl Cache {
    fn new() -> Self {
        Cache {
            data: RwLock::new(HashMap::new()),
        }
    }

    fn get(&self, key: &str) -> Option<String> {
        let data = self.data.read().unwrap();
        data.get(key).cloned()
    }

    fn set(&self, key: String, value: String) {
        let mut data = self.data.write().unwrap();
        data.insert(key, value);
    }
}

fn main() {
    let cache = Arc::new(Cache::new());
    cache.set("name".to_string(), "Rustacean".to_string());

    if let Some(val) = cache.get("name") {
        println!("{}", val);
    }
}

RwLock ensures read/write safety. Type system prevents races.

Common Patterns

Pattern 1: Temporary Mutable Borrow

Rust:

fn main() {
    let mut data = vec![1, 2, 3];

    {
        let temp = &mut data;  // Mutable borrow
        temp.push(4);
    }  // Borrow ends here

    println!("{:?}", data);  // Usable again
}

Pattern 2: Split Borrows

Rust:

fn main() {
    let mut data = vec![1, 2, 3, 4];

    // Can't borrow data twice mutably
    // But can split it:
    let (left, right) = data.split_at_mut(2);

    left[0] = 10;
    right[0] = 20;

    println!("{:?}", data);  // [10 2 20 4]
}

Pattern 3: Entry API (Avoiding Double Lookup)

Go:

if _, ok := cache[key]; !ok {
    cache[key] = expensive_computation()
}
return cache[key]

Two lookups!

Rust:

cache.entry(key).or_insert_with(|| expensive_computation());

One lookup, borrowing rules ensure safety.

The Mental Shift

Coming from Go:

  • Think: “Who owns this data?”
  • Ask: “Do I need to modify it?”
  • Plan: “How long does this reference need to live?”

In Go, you think:

  • “Pass this data”
  • “GC will handle cleanup”

In Rust, you think:

  • “Should I move, borrow, or clone?”
  • “Is this reference still valid?”
  • “Can this be borrowed immutably?”

Conclusion

Go’s memory model:

  • Simple: pass data around freely
  • Garbage collector handles cleanup
  • Runtime overhead for GC pauses
  • Potential for data races

Rust’s ownership model:

  • Complex: must track ownership and lifetimes
  • Compile-time memory management
  • Zero runtime overhead
  • Data races impossible (in safe Rust)

Trade-off:

  • Go: Easier to write, potential runtime issues
  • Rust: Harder to write, guaranteed safety

Next: How Rust achieves this without a garbage collector.


Go to Rust Series: ← The Rust Compiler | Series Overview | No Garbage Collector →


Ownership verdict: Hardest Rust concept for Go developers. But once it clicks, you understand why Rust is so fast and safe.