Rust for Python and Go Developers: Ownership Explained Without the Pain (2026)
Rust has been the most admired programming language for eight consecutive years in the Stack Overflow Developer Survey. It is no longer an academic curiosity — it powers AWS Firecracker (the VM engine behind AWS Lambda), the Android Linux kernel drivers, Microsoft Windows kernel components, and Meta's production infrastructure. If you are coming from Python or Go and curious what the hype is about, this tutorial explains Rust's core concepts using side-by-side comparisons you can map directly to what you already know.
Expect 40–80 hours of hands-on practice before the borrow checker stops feeling adversarial. This guide gives you the mental model to make those hours productive.
Why Rust in 2026?
Python gives you fast iteration speed and a huge ecosystem, but it needs a garbage collector and a runtime, which makes it unsuitable for OS kernels, embedded firmware, or latency-sensitive hot paths. Go improved on Python by compiling to native code, but it still uses a garbage collector — Go programs pause (briefly) for GC cycles, and the runtime adds a few megabytes to every binary.
Rust achieves memory safety without a garbage collector. There is no runtime, no GC pauses, and no null pointer exceptions by construction. Memory is managed at compile time through a system called ownership. The trade-off is a steeper learning curve: the compiler rejects programs that would be safe in Python or Go but could cause undefined behavior in a language without a runtime safety net.
When to choose Rust over Go or Python is a decision covered at the end of this tutorial. First, let us get the tools set up.
Cargo: Rust's Build Tool and Package Manager
Rust is installed via rustup, which also manages toolchain versions:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
rustc --version # rustc 1.78.0 or newer
cargo is the Swiss Army knife of Rust development — analogous to pip + setuptools + pytest + go build + go test in one tool:
cargo new myproject # Create a new project (binary)
cargo new mylib --lib # Create a library crate
cargo run # Compile and run
cargo build # Debug build (fast compile, slow binary)
cargo build --release # Optimized build (slow compile, fast binary)
cargo test # Run all tests
cargo add serde # Add a dependency (like pip install or go get)
cargo fmt # Format code
cargo clippy # Lint — catches many common mistakes
Cargo.toml is the project manifest, equivalent to pyproject.toml or go.mod:
[package]
name = "myproject"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
Dependencies are downloaded from crates.io, the Rust package registry. The Cargo.lock file pins exact versions, ensuring reproducible builds.
Ownership: The Core Idea
This is the concept that differentiates Rust from every other mainstream language. Understanding it unlocks everything else.
In Python, every object is heap-allocated and reference-counted. Multiple variables can point to the same object. The GC cleans up when the reference count drops to zero:
a = [1, 2, 3]
b = a # Both a and b point to the same list
b.append(4)
print(a) # [1, 2, 3, 4] — a is affected!
In Go, values are copied by default for value types, but slices are reference types (a slice header pointing to an underlying array):
a := []int{1, 2, 3}
b := a // b shares the underlying array with a
b = append(b, 4)
// Whether a is affected depends on capacity — this is a common Go gotcha
In Rust, every value has exactly one owner. When you assign a value to another variable, the original variable is moved — it no longer exists. There is no sharing by default:
let a = vec![1, 2, 3];
let b = a; // a is MOVED into b
// println!("{:?}", a); // Compile error: value moved
println!("{:?}", b); // Works fine: [1, 2, 3]
When the owner goes out of scope, Rust automatically frees the memory — at compile time, with zero runtime overhead. This is called RAII (Resource Acquisition Is Initialization), and it is what makes Rust memory-safe without a GC.
For types that implement the Copy trait (integers, booleans, floats, fixed-size arrays of Copy types), assignment copies the value instead of moving it:
let x = 5;
let y = x; // x is copied, not moved
println!("{}", x); // Works fine
Borrowing and References
Moving a value into every function that needs it would be impractical. Rust solves this with borrowing — passing a reference to a value without transferring ownership.
fn print_vec(v: &Vec<i32>) { // &Vec<i32> = immutable borrow
println!("{:?}", v);
}
fn add_one(v: &mut Vec<i32>) { // &mut Vec<i32> = mutable borrow
v.push(1);
}
let mut nums = vec![1, 2, 3];
print_vec(&nums); // Borrow immutably
add_one(&mut nums); // Borrow mutably
println!("{:?}", nums); // Owner still valid: [1, 2, 3, 1]
The borrow checker enforces two rules at compile time:
- You can have any number of immutable borrows (
&T) at the same time. - You can have exactly one mutable borrow (
&mut T) — and no immutable borrows simultaneously.
This eliminates an entire class of bugs: data races, iterator invalidation, use-after-free. If your code compiles, these bugs cannot exist at runtime.
Common borrow checker error to learn to read:
error[E0502]: cannot borrow `nums` as mutable because it is also borrowed as immutable
--> src/main.rs:5:5
|
4 | let r = &nums;
| ---- immutable borrow occurs here
5 | nums.push(4);
| ^^^^^^^^^^^^ mutable borrow occurs here
6 | println!("{:?}", r);
| - immutable borrow later used here
The fix is to ensure the immutable borrow's scope ends before the mutable borrow begins.
Lists: Python → Go → Rust
| Concept | Python | Go | Rust |
|---|---|---|---|
| Dynamic array | list | []T (slice) | Vec<T> |
| Create | a = [1, 2, 3] | a := []int{1,2,3} | let a = vec![1,2,3]; |
| Append | a.append(4) | a = append(a, 4) | a.push(4); |
| Length | len(a) | len(a) | a.len() |
| Index | a[0] | a[0] | a[0] |
| Slice | a[1:3] | a[1:3] | &a[1..3] |
| Iterate | for x in a: | for _, x := range a {} | for x in &a {} |
Lifetimes
Lifetimes are Rust's way of tracking how long a reference is valid. In most cases the compiler infers them automatically (called lifetime elision). You only need to annotate lifetimes when the compiler cannot figure out which reference a returned reference is tied to:
// The compiler needs to know: does the return value live as long as x or y?
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
The 'a annotation says: "the returned reference is valid for at least as long as both x and y." This is information the compiler needs to prove no dangling reference can occur. You will rarely write lifetimes for everyday application code; they appear mostly in library code that stores references inside structs.
Structs and Methods
Rust structs are comparable to Python dataclasses or Go structs. Methods are defined in a separate impl block:
Python:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
def area(self) -> float:
return self.width * self.height
Go:
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
Rust:
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// Associated function (like a class method / constructor)
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
// Method — &self = immutable borrow of the instance
fn area(&self) -> f64 {
self.width * self.height
}
// Mutable method
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
}
let mut r = Rectangle::new(3.0, 4.0);
println!("Area: {}", r.area()); // 12.0
r.scale(2.0);
println!("Area: {}", r.area()); // 48.0
Enums: More Powerful Than Python or Go
Python enums hold simple values. Go uses iota constants. Rust enums can hold different data in each variant, making them algebraic data types (similar to Haskell or Swift):
enum Shape {
Circle(f64), // holds radius
Rectangle(f64, f64), // holds width and height
Triangle { base: f64, height: f64 }, // holds named fields
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
}
Option\<T>: Replacing nil and None
Python's None and Go's nil are values that can be accidentally dereferenced, causing AttributeError or a nil pointer panic. Rust has no null. Instead, optional values use the Option<T> enum:
enum Option<T> {
Some(T), // A value exists
None, // No value
}
The compiler forces you to handle both cases:
fn find_user(id: u32) -> Option<String> {
if id == 1 { Some(String::from("Alice")) } else { None }
}
match find_user(1) {
Some(name) => println!("Found: {}", name),
None => println!("User not found"),
}
// Shorthand with if let
if let Some(name) = find_user(2) {
println!("Found: {}", name);
} else {
println!("Not found");
}
// Provide a default
let name = find_user(99).unwrap_or_else(|| String::from("Unknown"));
Result\<T, E>: Replacing Exceptions
Python uses try/except. Go uses multiple return values (T, error). Rust uses Result<T, E>:
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
// Pattern matching
match read_config("/etc/myapp/config.toml") {
Ok(contents) => println!("Config: {}", contents),
Err(e) => eprintln!("Failed to read config: {}", e),
}
The ? operator propagates errors automatically — it is equivalent to Go's if err != nil { return err } but much more concise:
fn process() -> Result<(), io::Error> {
let config = read_config("/etc/myapp/config.toml")?; // returns Err early if it fails
let data = fs::read_to_string("/var/data/input.txt")?;
println!("Config: {}\nData: {}", config, data);
Ok(())
}
Traits: Shared Behaviour
Traits are Rust's equivalent of Python protocols (structural subtyping) or Go interfaces, but they are checked at compile time and can include default implementations.
Standard library traits you will use constantly:
Display— enablesprintln!("{}", value)Debug— enablesprintln!("{:?}", value)(derived automatically)Clone— enables.clone()to deep-copy a valueIterator— enables.map(),.filter(),.collect(), etc.
Defining a custom trait:
trait Summary {
fn summarize(&self) -> String;
// Default implementation
fn preview(&self) -> String {
format!("{}...", &self.summarize()[..50.min(self.summarize().len())])
}
}
struct Article {
title: String,
body: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{}: {}", self.title, self.body)
}
}
// Trait bound: accept any type that implements Summary
fn print_summary(item: &impl Summary) {
println!("{}", item.summarize());
}
Iterators
Rust iterators are lazy and composable, similar to Python generator expressions or list comprehensions:
# Python
squares = [x * x for x in range(10) if x % 2 == 0]
// Go — no built-in functional iterators, manual loop required
squares := []int{}
for x := 0; x < 10; x++ {
if x%2 == 0 {
squares = append(squares, x*x)
}
}
// Rust — lazy, zero-cost abstraction, compiles to same code as the manual loop
let squares: Vec<i32> = (0..10)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.collect();
Iterators in Rust are zero-cost abstractions — the compiler unrolls them into the equivalent loop, so there is no performance penalty for using the functional style.
Pattern Matching
The match expression is exhaustive (the compiler forces you to handle every case) and supports destructuring, range patterns, and guards:
let value: Option<i32> = Some(42);
let description = match value {
None => String::from("nothing"),
Some(n) if n < 0 => format!("negative: {}", n),
Some(0) => String::from("zero"),
Some(n) => format!("positive: {}", n),
};
// Destructuring a tuple
let point = (1, -1);
match point {
(0, 0) => println!("origin"),
(x, 0) | (0, x) => println!("on an axis at {}", x),
(x, y) if x == y => println!("on the diagonal"),
(x, y) => println!("point ({}, {})", x, y),
}
When to Use Rust vs Go vs Python
| Criterion | Python | Go | Rust |
|---|---|---|---|
| Startup / iteration speed | Fastest | Fast | Slower (fighting the borrow checker initially) |
| Runtime performance | Slow (CPython) | Fast (GC pauses ~1ms) | Fastest (no GC, no runtime) |
| Memory usage | High | Moderate | Minimal |
| Memory safety | Runtime errors | Runtime panics + nil | Guaranteed at compile time |
| Concurrency safety | GIL limits | Race detector (runtime) | Guaranteed at compile time |
| Binary size | Large (+ Python) | Small (+ Go runtime) | Very small (no runtime) |
| Ecosystem maturity | Excellent | Excellent | Good and growing fast |
| Best for | Scripting, ML, data science, web APIs | Cloud services, CLIs, DevOps tooling | OS kernels, embedded, WebAssembly, game engines, security-critical code |
Choose Python when you need fast prototyping, ML/data work, or scripting. The ecosystem (NumPy, pandas, PyTorch) is unmatched.
Choose Go when you are building networked services, CLIs, or infrastructure tooling and want fast compile times, simple concurrency with goroutines, and an easy-to-read codebase that scales across large teams.
Choose Rust when you need maximum performance and safety guarantees without a GC — OS-level code, WebAssembly modules, game engines, high-frequency trading, or anywhere a crash or memory vulnerability is unacceptable.
Summary
The key mental shifts coming from Python or Go:
- There is one owner. When you pass a value, decide whether to move it, borrow it (
&T), or mutably borrow it (&mut T). - The compiler is your test suite for memory safety. If it compiles, an entire class of bugs is provably absent.
Option<T>andResult<T,E>replace null and exceptions — the compiler forces you to handle every failure path.- Iterators and pattern matching make code expressive without sacrificing performance.
- Cargo handles everything build-related — there is no equivalent of wrestling with
setuptools,Makefile, orgo generate.
The 40–80 hour estimate to get comfortable with the borrow checker is real, but so is the payoff: after that investment, you will write code that is simultaneously as fast as C and as safe as Python, with no runtime surprises in production.