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:
- Each value has one owner
- When the owner goes out of scope, the value is dropped
- 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.