rust cheat sheet / Basics

Rust Cheat Sheet

Ownership, traits, iterators, async, and Cargo in one Rust cheat sheet. Every snippet compiles, every snippet copies.

/ to focus
300 snippets 19 sections 0 signup, 0 ads
level

Search

Basics

Variables, mutability, primitive types, and the shape of a Rust program. Everything here is stack-first and known at compile time.

Program Skeletoncore
Entry point
fn main() { println!("Hello, world!"); }
Main that can fail
fn main() -> Result<(), Box<dyn std::error::Error>> { let text = std::fs::read_to_string("config.toml")?; println!("{text}"); Ok(()) }
Comments and doc comments
// line comment /* block comment */ /// Doc comment for the item below //! Doc comment for the enclosing module
Variables & Mutabilitycore
Immutable by default
let x = 5; // x = 6; // error: cannot assign twice
Mutable binding
let mut count = 0; count += 1;
Explicit type annotation
let ratio: f64 = 0.75; let flag: bool = true;
Shadowing, a new binding with the same name
let spaces = " "; let spaces = spaces.len(); // now a usize
Constant, inlined everywhere it is used
const MAX_USERS: u32 = 100_000;
Static, one memory location for the whole program
static GREETING: &str = "hello";
Destructuring a tuple into several bindings
let (width, height) = (1920, 1080);
Ignore a value with the underscore pattern
let (_, port) = ("localhost", 8080);
Scalar Typescore
TypeSizeRange or meaning
i8 u81 byte-128..127 and 0..255
i16 u162 bytes-32768..32767 and 0..65535
i32 u324 bytesi32 is the default integer type
i64 u648 bytestimestamps, large counters
i128 u12816 bytesbig integer arithmetic
isize usizepointerindexing and lengths use usize
f32 f644 or 8IEEE 754, f64 is the default float
bool1 bytetrue or false, no integer coercion
char4 bytesone Unicode scalar value, not one byte
()0 bytesthe unit type, Rust has no void
Numbers & Castingcore
Literal forms
let dec = 98_222; let hex = 0xff; let oct = 0o77; let bin = 0b1111_0000; let byte = b'A'; // u8 only
Suffix a literal to pin its type
let big = 42u64; let small = 3.5f32;
Cast between numeric types
let n = 7_i64; let m = n as u8; // truncates, never panics
Checked conversion that reports overflow
use std::convert::TryFrom; let small = u8::try_from(300i32); // Err(...)
Overflow-aware arithmetic
let a: u8 = 250; a.checked_add(10); // None a.saturating_add(10); // 255 a.wrapping_add(10); // 4 a.overflowing_add(10); // (4, true)
Integer division truncates, remainder keeps the sign
let q = 7 / 2; // 3 let r = -7 % 2; // -1
Useful numeric helpers
i32::MAX; f64::EPSILON; (2.0_f64).sqrt(); (-3i32).abs(); 10i32.pow(3); 2.5f64.round();
Compound Typescore
Tuple, fixed length, mixed types
let point: (i32, i32, &str) = (3, 7, "origin"); let x = point.0;
Array, fixed length, one type, stack allocated
let days: [u8; 3] = [1, 2, 3]; let zeros = [0u8; 16]; // 16 zero bytes
Slice, a borrowed window into a sequence
let all = [10, 20, 30, 40]; let middle = &all[1..3]; // [20, 30]
Safe indexing that returns Option
let v = [1, 2, 3]; match v.get(9) { Some(n) => println!("{n}"), None => println!("out of range"), }
Printing & Formattingstd
Inline variable capture, Rust 2021 and later
let name = "Ada"; println!("Hello {name}");
Positional and named arguments
println!("{0} {1} {0}", "a", "b"); println!("{who} is {age}", who = "Ada", age = 36);
Debug and pretty Debug output
println!("{:?}", vec![1, 2, 3]); println!("{:#?}", config); // multi-line
Width, alignment, precision, padding
println!("{:>8}", "right"); println!("{:<8}|", "left"); println!("{:^8}", "mid"); println!("{:08.3}", 3.14159); // 0003.142
Number bases and scientific notation
println!("{:b} {:o} {:x} {:X}", 255, 255, 255, 255); println!("{:#x}", 255); // 0xff println!("{:e}", 1234.5); // 1.2345e3
Build a String instead of printing
let s = format!("{}-{}", "id", 42);
Write to standard error
eprintln!("warning: {} retries left", n);
Operatorscore
OperatorMeaningNotes
+ - * / %arithmeticboth sides must be the same type
== != < > <= >=comparisonneeds PartialEq or PartialOrd
&& || !logicalshort circuits, bool only
& | ^ << >>bitwisealso works on bool for & and |
& &mutborrowshared and exclusive references
*dereferencereads through a reference or pointer
?tryearly return on Err or None
.. ..=rangeexclusive and inclusive
ascastprimitive conversion, can truncate
|args|closureanonymous function literal

Ownership & Borrowing

The part that makes Rust Rust. Memory is freed at a predictable point with no garbage collector, because the compiler tracks who owns what.

The three rules

Each value in Rust has an owner, a single variable responsible for it.
There can be only one owner at a time. Assigning or passing a non-Copy value moves ownership.
When the owner goes out of scope, the value is dropped and its memory is released.
Move, Copy, Clonememory
Heap values move on assignment
let a = String::from("hi"); let b = a; // a is moved into b // println!("{a}"); // error: value borrowed after move
Copy types are duplicated, not moved
let x = 5; let y = x; // both usable, integers are Copy
Explicit deep copy
let a = String::from("hi"); let b = a.clone(); // both valid, heap data duplicated
Passing to a function moves ownership
fn consume(s: String) { /* s dropped here */ } let text = String::from("bye"); consume(text); // text is gone
Return a value to give ownership back
fn tag(mut s: String) -> String { s.push_str("!"); s }
References & Borrowingmemory
Shared borrow, read only, unlimited copies
fn length(s: &String) -> usize { s.len() } let owned = String::from("hello"); let n = length(&owned); // owned still usable
Mutable borrow, exclusive, one at a time
fn shout(s: &mut String) { s.push_str("!!"); } let mut owned = String::from("hey"); shout(&mut owned);
The borrowing rule in one line
// Any number of &T, OR exactly one &mut T. // Never both at the same time.
Borrows end at last use, not at end of block
let mut v = vec![1, 2, 3]; let first = &v[0]; println!("{first}"); // borrow ends here v.push(4); // allowed
Dereference to read or write through a reference
let mut n = 10; let r = &mut n; *r += 5; println!("{n}"); // 15
Prefer slices over owned parameters
fn first_word(s: &str) -> &str { s.split_whitespace().next().unwrap_or("") }
Split a slice to get two mutable halves
let mut data = [1, 2, 3, 4]; let (left, right) = data.split_at_mut(2); left[0] = 9; right[0] = 8;
Scope, Drop, and Moves Outmemory
Drop runs automatically at end of scope
{ let file = File::open("a.txt")?; } // file closed here, no finally block needed
Drop something early
let guard = lock.lock().unwrap(); drop(guard); // release before the scope ends
Take a value out and leave the default behind
use std::mem; let old = mem::take(&mut self.buffer); // buffer is now empty let prev = mem::replace(&mut slot, new_value); mem::swap(&mut a, &mut b);
Common borrow-checker fixes
// 1. clone the value if it is cheap // 2. shorten the borrow with an inner block // 3. restructure so reads finish before writes // 4. use indexes instead of holding a reference // 5. reach for RefCell or Rc only when truly needed

Structs & Enums

Rust models data with product types (structs) and sum types (enums). Enums with data are where most of the expressive power lives.

Structscore
Named-field struct
struct User { name: String, age: u32, active: bool, } let u = User { name: "Ada".into(), age: 36, active: true };
Tuple struct, fields by position
struct Point(f64, f64); let p = Point(1.0, 2.0); println!("{}", p.0);
Newtype wrapper for type safety
struct Meters(f64); struct Seconds(f64); // the compiler now refuses to mix them up
Unit struct, no data at all
struct Marker;
Field init shorthand
fn build(name: String, age: u32) -> User { User { name, age, active: true } }
Struct update syntax
let older = User { age: 37, ..u };
Default values
#[derive(Default)] struct Config { retries: u32, verbose: bool } let c = Config { retries: 3, ..Default::default() };
Methods & Associated Functionscore
impl block with a constructor and methods
impl User { fn new(name: &str) -> Self { Self { name: name.to_string(), age: 0, active: true } } fn is_adult(&self) -> bool { self.age >= 18 } fn birthday(&mut self) { self.age += 1; } fn into_name(self) -> String { self.name } }
Associated constant
impl Circle { const PI: f64 = 3.14159; }
Builder pattern with method chaining
impl Request { fn timeout(mut self, secs: u64) -> Self { self.timeout = secs; self } fn retries(mut self, n: u32) -> Self { self.retries = n; self } } let r = Request::new(url).timeout(30).retries(3);
Several impl blocks are allowed
impl User { /* core api */ } impl User { /* helpers, feature gated */ }
Enumscore
Simple enum
enum Direction { North, South, East, West } let d = Direction::North;
Enum variants carrying data
enum Shape { Circle(f64), Rect { w: f64, h: f64 }, Empty, }
Methods on an enum
impl Shape { fn area(&self) -> f64 { match self { Shape::Circle(r) => 3.14159 * r * r, Shape::Rect { w, h } => w * h, Shape::Empty => 0.0, } } }
Explicit discriminant values
#[derive(Clone, Copy)] enum Status { Ok = 200, NotFound = 404, Error = 500 } let code = Status::NotFound as u16;
The two enums you use every day
enum Option<T> { Some(T), None } enum Result<T, E> { Ok(T), Err(E) }
Recursive enum needs indirection
enum Tree { Leaf(i32), Node(Box<Tree>, Box<Tree>), }
Derive Macrosstd
The usual set
#[derive(Debug, Clone, PartialEq)] struct Id(u64);
Full-strength set for a value type
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] struct Version(u8, u8, u8);
Serde, the de facto serialization derive
use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Payload { user_id: u64, is_active: bool }
Conversion Matrixstd
FromToHow
&strStrings.to_string() or s.to_owned() or String::from(s)
String&str&s or s.as_str()
&str&[u8]s.as_bytes()
&[u8]&strstd::str::from_utf8(b)? or from_utf8_lossy(b)
StringVec<u8>s.into_bytes()
Vec<u8>StringString::from_utf8(v)?
&strnumbers.parse::<i32>()?
numberStringn.to_string() or format!("{n}")
charu32c as u32
u32charchar::from_u32(n)
charStringc.to_string()
&strVec<char>s.chars().collect()
Vec<T>&[T]&v or v.as_slice()
&[T]Vec<T>s.to_vec() (T must be Clone)
[T; N]Vec<T>arr.to_vec() or Vec::from(arr)
Vec<T>[T; N]v.try_into()? (length checked)
i64u8n as u8 (truncates) or u8::try_from(n)?
f64i32x as i32 (truncates toward zero)
Option<T>Result<T, E>opt.ok_or(err)?
Result<T, E>Option<T>res.ok()
IteratorVec / String / HashMapiter.collect()
&Path&strp.to_str() returns Option

Control Flow & Pattern Matching

In Rust almost everything is an expression that returns a value, and match is exhaustive so the compiler catches the case you forgot.

Conditionalscore
if with no parentheses, condition must be bool
if n > 10 { println!("big"); } else if n > 5 { println!("medium"); } else { println!("small"); }
if as an expression, Rust has no ternary
let label = if n % 2 == 0 { "even" } else { "odd" };
if let, match a single pattern
if let Some(user) = find_user(id) { println!("{}", user.name); } else { println!("not found"); }
let else, bind or bail out
let Some(user) = find_user(id) else { return Err("no such user".into()); }; // user is in scope from here on
Loopscore
for over a range
for i in 0..5 { print!("{i} "); } // 0 1 2 3 4 for i in 0..=5 { print!("{i} "); } // 0 1 2 3 4 5 for i in (0..10).step_by(2) { } for i in (0..5).rev() { }
for over a collection, three flavours
for item in &items { } // borrow each item for item in &mut items { } // mutate each item for item in items { } // consume the collection
while and while let
while queue.len() > 0 { queue.pop(); } while let Some(top) = stack.pop() { println!("{top}"); }
loop, infinite until you break
loop { if done() { break; } }
break with a value
let found = loop { let n = next(); if n > 100 { break n; } };
Labelled loops for breaking out of nesting
'outer: for row in &grid { for cell in row { if *cell == target { break 'outer; } } }
Index and value together
for (i, item) in items.iter().enumerate() { println!("{i}: {item}"); }
matchcore
Exhaustive match with a catch-all
match code { 200 => "ok", 404 => "not found", 500..=599 => "server error", _ => "unknown", }
Multiple patterns in one arm
match c { 'a' | 'e' | 'i' | 'o' | 'u' => "vowel", 'a'..='z' => "consonant", _ => "other", }
Match guard, an extra condition on an arm
match pair { (x, y) if x == y => "diagonal", (x, _) if x == 0 => "on the y axis", _ => "somewhere else", }
Destructure structs and enums in place
match shape { Shape::Rect { w, h } if w == h => println!("square {w}"), Shape::Rect { w, h } => println!("{w} by {h}"), Shape::Circle(r) => println!("radius {r}"), Shape::Empty => {} }
Bind the whole value while matching part of it
match msg { n @ 1..=9 => println!("single digit {n}"), n => println!("other {n}"), }
Ignore the rest of a struct or slice
let Point { x, .. } = origin; match slice { [first, .., last] => println!("{first} {last}"), [only] => println!("{only}"), [] => println!("empty"), }
Match on a reference and bind by reference
match &maybe_name { Some(name) => println!("{name}"), None => println!("anonymous"), }
matches! for a quick boolean test
if matches!(status, Status::Ok | Status::NotFound) { // ... }

Functions & Closures

Functions are explicit about types at the boundary and inferred inside. Closures capture their environment and come in three flavours.

Functionscore
Parameters and return type
fn add(a: i32, b: i32) -> i32 { a + b // no semicolon means this is the return value }
Early return
fn safe_div(a: f64, b: f64) -> Option<f64> { if b == 0.0 { return None; } Some(a / b) }
Function that never returns
fn fail(msg: &str) -> ! { panic!("{msg}"); }
Nested function, no environment capture
fn outer() { fn helper(n: u32) -> u32 { n * 2 } println!("{}", helper(21)); }
Function pointer as a value
fn double(n: i32) -> i32 { n * 2 } let f: fn(i32) -> i32 = double; let out: Vec<i32> = vec![1, 2, 3].into_iter().map(double).collect();
Generic function with a trait bound
fn largest<T: PartialOrd>(list: &[T]) -> &T { let mut max = &list[0]; for item in list { if item > max { max = item; } } max }
Closurescore
Closure literal, types usually inferred
let square = |n: i32| n * n; let sum = |a, b| a + b; println!("{}", square(4));
Multi-line closure body
let describe = |n: i32| { let kind = if n > 0 { "positive" } else { "non positive" }; format!("{n} is {kind}") };
Capturing the environment
let factor = 3; let scale = |n: i32| n * factor; // borrows factor
move closure takes ownership of captures
let data = vec![1, 2, 3]; let handle = std::thread::spawn(move || { println!("{data:?}"); });
The three closure traits
// Fn - borrows, callable many times // FnMut - borrows mutably, callable many times // FnOnce - consumes captures, callable once
Take a closure as a parameter
fn apply<F: Fn(i32) -> i32>(f: F, n: i32) -> i32 { f(n) } fn apply_impl(f: impl Fn(i32) -> i32, n: i32) -> i32 { f(n) }
Return a closure
fn adder(n: i32) -> impl Fn(i32) -> i32 { move |x| x + n } fn boxed(n: i32) -> Box<dyn Fn(i32) -> i32> { Box::new(move |x| x + n) }
Store closures in a struct
struct Handler<F: Fn(&str)> { callback: F } struct DynHandler { callback: Box<dyn Fn(&str) + Send + 'static> }

Strings & Text

Rust strings are always valid UTF-8, which is why there is no direct character indexing. Two types cover almost every case: String and &str.

String vs &strstd
AspectString&str
ownershipownedborrowed
storageheap, growableview into existing bytes
mutationpush, push_str, clearread only
literalsString::from(...)let s = ...
use forfields, return valuesfunction parameters
converts.as_str()s.to_string()
Creating & Growingstd
Four ways to build a String
let a = String::new(); let b = String::from("hello"); let c = "hello".to_string(); let d: String = "hello".into();
Append
let mut s = String::from("hello"); s.push(' '); s.push_str("world");
Concatenate
let joined = format!("{a} {b}"); let plus = a + &b; // a is moved, b is borrowed
Multi-line and raw strings
let sql = r"SELECT * FROM t WHERE path = 'C:\tmp'"; let json = r#"{"key": "value"}"#; let block = "line one line two";
Inspect & Slicestd
Length is in bytes, not characters
let s = "caffè"; s.len(); // 6 bytes s.chars().count(); // 5 characters
Search and test
s.contains("ff"); s.starts_with("ca"); s.ends_with("è"); s.find('f'); // Option<usize> s.is_empty();
Trim, case, replace
" padded ".trim(); " padded".trim_start(); "MiXeD".to_lowercase(); "quiet".to_uppercase(); "a-b-c".replace('-', "_");
Split into parts
let parts: Vec<&str> = "a,b,c".split(',').collect(); let words: Vec<&str> = "one two".split_whitespace().collect(); let lines: Vec<&str> = text.lines().collect(); let (head, tail) = "key=value".split_once('=').unwrap();
Iterate characters with byte offsets
for (idx, ch) in "héllo".char_indices() { println!("{idx} {ch}"); }
Join a collection of strings
let csv = vec!["a", "b", "c"].join(",");
Parse text into a number
let n: i32 = "42".parse()?; let f = "3.14".parse::<f64>().unwrap_or(0.0);
Bytes and lossy conversion
let bytes = s.as_bytes(); let back = String::from_utf8(bytes.to_vec())?; let lossy = String::from_utf8_lossy(&raw); // never fails

Collections

Vec and HashMap cover the majority of real code. The rest of std::collections exists for ordering, deduplication, and queue semantics.

Choosing a Collectionstd
TypeUse it whenLookup
Vec<T>ordered list, push and indexO(1) by index
VecDeque<T>queue, push and pop at both endsO(1) both ends
HashMap<K,V>key to value, order does not matterO(1) average
BTreeMap<K,V>key to value, sorted iterationO(log n)
HashSet<T>membership tests, deduplicationO(1) average
BTreeSet<T>sorted unique values, range queriesO(log n)
BinaryHeap<T>always pop the largest itemO(1) peek
[T; N]fixed size known at compile timeO(1), no heap
Vecstd
Create
let mut v: Vec<i32> = Vec::new(); let v = vec![1, 2, 3]; let v = vec![0u8; 1024]; // 1024 zeros let v = Vec::with_capacity(100); // preallocate
Add and remove
v.push(4); v.pop(); // Option<T> from the end v.insert(0, 99); // shifts everything right v.remove(0); // shifts everything left v.swap_remove(0); // O(1), does not preserve order v.clear();
Read
v[0]; // panics if out of range v.get(0); // Option<&T> v.first(); v.last(); v.len(); v.is_empty(); v.contains(&3);
Sort and deduplicate
v.sort(); // needs Ord v.sort_by(|a, b| b.cmp(a)); // descending v.sort_by_key(|item| item.priority); v.sort_unstable(); // faster, no stability v.dedup(); // removes consecutive equals v.reverse();
Filter in place and extend
v.retain(|n| *n % 2 == 0); v.extend(other.iter().copied()); v.append(&mut other); // empties other v.truncate(10);
Binary search on a sorted vector
match v.binary_search(&target) { Ok(idx) => println!("found at {idx}"), Err(idx) => v.insert(idx, target), // keeps it sorted }
Drain a range out of a vector
let removed: Vec<_> = v.drain(0..3).collect();
HashMapstd
Create and insert
use std::collections::HashMap; let mut scores: HashMap<String, u32> = HashMap::new(); scores.insert("ada".to_string(), 10);
Build from pairs
let m = HashMap::from([("a", 1), ("b", 2)]);
Read with a fallback
scores.get("ada"); // Option<&u32> scores.get("ada").copied().unwrap_or(0); scores.contains_key("ada");
Insert only when missing
scores.entry("ada".into()).or_insert(0);
Counter idiom, the most useful entry pattern
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; }
Group items into buckets
let mut groups: HashMap<char, Vec<&str>> = HashMap::new(); for name in names { groups.entry(name.chars().next().unwrap()) .or_default() .push(name); }
Iterate
for (key, value) in &scores { println!("{key}: {value}"); } for key in scores.keys() { } for value in scores.values_mut() { *value += 1; }
Remove and update
scores.remove("ada"); // Option<V> if let Some(v) = scores.get_mut("ada") { *v += 1; }
Sets, Queues, Heapsstd
HashSet basics
use std::collections::HashSet; let mut seen = HashSet::new(); seen.insert("a"); // returns false if already there seen.contains("a"); seen.remove("a");
Set algebra
let both: HashSet<_> = a.intersection(&b).collect(); let either: HashSet<_> = a.union(&b).collect(); let only_a: HashSet<_> = a.difference(&b).collect(); a.is_subset(&b);
BTreeMap keeps keys sorted
use std::collections::BTreeMap; let mut m = BTreeMap::new(); m.insert(3, "c"); m.insert(1, "a"); for (k, v) in &m { } // always 1 then 3 let slice = m.range(2..).next(); // first key at or above 2
VecDeque as a queue
use std::collections::VecDeque; let mut q = VecDeque::new(); q.push_back(1); q.push_front(0); q.pop_front(); // Option<T>
BinaryHeap as a priority queue
use std::collections::{BinaryHeap, cmp::Reverse}; let mut heap = BinaryHeap::new(); heap.push(5); heap.push(1); heap.pop(); // Some(5), max first let mut min_heap = BinaryHeap::new(); min_heap.push(Reverse(5)); // wrap for min first

Iterators

Iterators are lazy and compile down to code as fast as a hand-written loop. Nothing runs until a consuming adapter asks for a value.

Getting an Iteratorcore
Three ways, three ownership models
v.iter() // yields &T v.iter_mut() // yields &mut T v.into_iter() // yields T, consumes the collection
Turn references into values
let doubled: Vec<i32> = v.iter().copied().map(|n| n * 2).collect(); let owned: Vec<String> = names.iter().cloned().collect();
Transformingcore
map and filter
let evens: Vec<i32> = (1..=20) .filter(|n| n % 2 == 0) .map(|n| n * n) .collect();
filter_map, filter and transform in one pass
let nums: Vec<i32> = lines .iter() .filter_map(|l| l.trim().parse().ok()) .collect();
flat_map and flatten
let words: Vec<&str> = lines.iter().flat_map(|l| l.split(' ')).collect(); let flat: Vec<i32> = nested.into_iter().flatten().collect();
enumerate, zip, chain
for (i, item) in items.iter().enumerate() { } for (name, age) in names.iter().zip(ages.iter()) { } let all: Vec<_> = a.iter().chain(b.iter()).collect();
Slice the stream
iter.take(5); iter.skip(2); iter.step_by(3); iter.take_while(|n| *n < 100); iter.skip_while(|n| *n < 10); iter.rev();
Inspect values passing through
let total: i32 = v.iter() .inspect(|n| println!("saw {n}")) .sum();
Windows and chunks over a slice
for pair in data.windows(2) { } // overlapping for group in data.chunks(3) { } // non overlapping
Consumingcore
collect into whatever you annotate
let v: Vec<i32> = iter.collect(); let s: String = chars.collect(); let m: HashMap<&str, i32> = pairs.collect(); let set: HashSet<i32> = iter.collect();
Aggregate
let total: i32 = v.iter().sum(); let product: i32 = v.iter().product(); let n = v.iter().count(); let max = v.iter().max(); let min = v.iter().min();
Max by a computed key
let oldest = people.iter().max_by_key(|p| p.age); let cheapest = items.iter() .min_by(|a, b| a.price.partial_cmp(&b.price).unwrap());
fold, the general reducer
let sum = v.iter().fold(0, |acc, n| acc + n); let sentence = words.iter() .fold(String::new(), |mut acc, w| { acc.push_str(w); acc });
Search and test
v.iter().find(|n| **n > 10); // Option<&T> v.iter().position(|n| *n == 7); // Option<usize> v.iter().any(|n| *n < 0); v.iter().all(|n| *n > 0);
Split a stream into two vectors
let (pass, fail): (Vec<_>, Vec<_>) = results.into_iter().partition(|r| r.ok);
Collect a Result out of an iterator of Results
let parsed: Result<Vec<i32>, _> = lines .iter() .map(|l| l.parse::<i32>()) .collect();
Running totals with scan
let running: Vec<i32> = v.iter().scan(0, |acc, n| { *acc += n; Some(*acc) }).collect();
Peek at the next value without consuming it
let mut it = v.iter().peekable(); while let Some(n) = it.next() { if it.peek().is_some() { print!("{n}, "); } else { print!("{n}"); } }
Custom Iteratorspattern
Implement Iterator, get 70 adapters for free
struct Counter { n: u32 } impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<u32> { if self.n < 5 { self.n += 1; Some(self.n) } else { None } } } let total: u32 = Counter { n: 0 }.zip(Counter { n: 1 }).map(|(a, b)| a * b).sum();
Generate an endless sequence
let powers: Vec<u64> = std::iter::successors(Some(1u64), |n| Some(n * 2)) .take(10) .collect(); let ones: Vec<i32> = std::iter::repeat(1).take(3).collect(); let once = std::iter::once("only");

Error Handling

Recoverable failures are values of type Result. Unrecoverable bugs panic. There is no exception mechanism to catch in between.

Optioncore
Handle both cases
match maybe_name { Some(name) => println!("{name}"), None => println!("anonymous"), }
Fallbacks without matching
opt.unwrap_or(0); opt.unwrap_or_default(); opt.unwrap_or_else(|| expensive()); opt.expect("config must be loaded first");
Transform without unwrapping
opt.map(|n| n * 2); opt.and_then(|n| checked_div(n, 2)); // flat map opt.filter(|n| *n > 0); opt.or(Some(fallback)); opt.take(); // leaves None behind opt.replace(new_value);
Test without consuming
opt.is_some(); opt.is_none(); if let Some(v) = opt.as_ref() { } opt.as_deref(); // Option<String> to Option<&str>
Bridge Option and Result
opt.ok_or("value was missing")?; opt.ok_or_else(|| MyError::Missing)?; result.ok(); // Result to Option, discards the error
Result & the ? Operatorcore
The long form
let contents = match std::fs::read_to_string(path) { Ok(text) => text, Err(e) => return Err(e), };
The same thing with ?
let contents = std::fs::read_to_string(path)?;
Chain several fallible calls
fn load(path: &str) -> Result<Config, ConfigError> { let text = std::fs::read_to_string(path)?; let parsed: Config = toml::from_str(&text)?; parsed.validate()?; Ok(parsed) }
Map the error instead of propagating it raw
let n: u32 = input .parse() .map_err(|e| format!("bad port number: {e}"))?;
Combinators on Result
res.map(|v| v + 1); res.and_then(|v| next_step(v)); res.unwrap_or(0); res.is_ok(); res.is_err(); res.ok(); res.err();
Custom Error Typespattern
An error enum by hand
use std::fmt; #[derive(Debug)] enum ConfigError { NotFound(String), Invalid { field: String }, } impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ConfigError::NotFound(p) => write!(f, "no config at {p}"), ConfigError::Invalid { field } => write!(f, "bad field {field}"), } } } impl std::error::Error for ConfigError {}
Let ? convert into your error type
impl From<std::io::Error> for ConfigError { fn from(e: std::io::Error) -> Self { ConfigError::NotFound(e.to_string()) } }
thiserror, the same thing with far less code
use thiserror::Error; #[derive(Error, Debug)] enum ConfigError { #[error("no config at {0}")] NotFound(String), #[error("io failure")] Io(#[from] std::io::Error), }
anyhow for application code
use anyhow::{Context, Result}; fn run() -> Result<()> { let cfg = std::fs::read_to_string("app.toml") .context("could not read app.toml")?; Ok(()) }
Boxed error for quick prototypes
type BoxResult<T> = Result<T, Box<dyn std::error::Error>>;
Panicscareful
Deliberate abort
panic!("invariant broken: {state:?}"); unreachable!("parser guarantees this cannot happen"); todo!(); unimplemented!();
Assertions
assert!(n > 0, "n must be positive, got {n}"); assert_eq!(actual, expected); debug_assert!(index < len); // compiled out in release
Where panics really come from
// v[99] on a shorter slice // .unwrap() on None or Err // integer divide by zero // arithmetic overflow in debug builds // .expect() with a failed precondition
Get a backtrace
RUST_BACKTRACE=1 cargo run RUST_BACKTRACE=full cargo run

Traits & Generics

Traits describe shared behaviour, generics make code work for many types, and monomorphisation means you pay no runtime cost for either.

Defining & Implementingcore
Trait with a required and a default method
trait Greet { fn name(&self) -> String; fn hello(&self) -> String { format!("Hello, {}", self.name()) } }
Implement it for your type
impl Greet for User { fn name(&self) -> String { self.name.clone() } }
Implement a std trait so println works
use std::fmt; impl fmt::Display for User { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} ({})", self.name, self.age) } }
Supertrait, requiring another trait
trait Loggable: std::fmt::Debug { fn log(&self) { println!("{self:?}"); } }
Associated type instead of a generic parameter
trait Repository { type Item; fn get(&self, id: u64) -> Option<Self::Item>; } impl Repository for UserStore { type Item = User; fn get(&self, id: u64) -> Option<User> { /* ... */ } }
Blanket implementation for every matching type
impl<T: fmt::Display> Loggable for T { fn log(&self) { println!("{self}"); } }
The orphan rule
// You may implement a trait for a type only if // the trait OR the type is defined in your crate. // Wrap foreign types in a newtype to get around it. struct MyVec(Vec<i32>);
Generics & Boundscore
Generic struct
struct Pair<T> { left: T, right: T } impl<T: std::fmt::Debug> Pair<T> { fn show(&self) { println!("{:?} {:?}", self.left, self.right); } }
Bounds, three equivalent styles
fn print<T: Display>(item: T) { } fn print(item: impl Display) { } fn print<T>(item: T) where T: Display { }
Multiple bounds and where clauses
fn process<T, U>(a: T, b: U) -> String where T: Display + Clone, U: Debug + Default, { format!("{a} {b:?}") }
Conditional implementation
impl<T: Ord> Pair<T> { fn largest(&self) -> &T { if self.left > self.right { &self.left } else { &self.right } } }
Turbofish, naming the type at the call site
let n = "42".parse::<i32>()?; let v = Vec::<String>::new(); let out = iter.collect::<Vec<_>>();
Const generics, size as a type parameter
fn sum<const N: usize>(arr: [i32; N]) -> i32 { arr.iter().sum() }
Static vs Dynamic Dispatchpattern
Static dispatch, one specialised copy per type
fn draw_all<T: Draw>(items: &[T]) { for item in items { item.draw(); } }
Dynamic dispatch, mixed types in one collection
let shapes: Vec<Box<dyn Draw>> = vec![ Box::new(Circle { r: 1.0 }), Box::new(Square { side: 2.0 }), ]; for s in &shapes { s.draw(); }
Trait object as a parameter
fn render(item: &dyn Draw) { item.draw(); }
Object safety, why some traits cannot be dyn
// A trait is object safe only if no method // returns Self and no method is generic. // Split the generic methods into a separate trait // if you need both dispatch styles.
Traits Worth Knowingstd
TraitGives you
Debugthe {:?} format specifier
Displaythe {} specifier and to_string
Clone / Copyexplicit deep copy and implicit bitwise copy
PartialEq / Eq== and use as a HashMap key
PartialOrd / Ordcomparison operators and sort
Hashmembership in HashMap and HashSet
DefaultType::default() and struct update syntax
From / Intoinfallible conversion, powers .into()
TryFrom / TryIntofallible conversion returning Result
Iteratorfor loops and every adapter method
IntoIteratorlets your type be used in a for loop
FromIteratorlets collect build your type
Derefauto dereference, how Box and String work
Dropcustom cleanup when a value goes out of scope
Send / Syncsafe to move to, or share across, threads
FromStrthe .parse() method on strings
Conversionsstd
Implement From, get Into for free
impl From<&str> for User { fn from(name: &str) -> Self { User::new(name) } } let u: User = "Ada".into(); let u2 = User::from("Grace");
Fallible conversion
impl TryFrom<i64> for Port { type Error = String; fn try_from(n: i64) -> Result<Self, Self::Error> { if (1..=65535).contains(&n) { Ok(Port(n as u16)) } else { Err(format!("{n} is not a valid port")) } } }
Parse a custom type from a string
impl std::str::FromStr for Version { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { /* ... */ } } let v: Version = "1.2.3".parse()?;

Lifetimes

Lifetime annotations do not change how long anything lives. They describe a relationship the compiler must already be able to verify.

Basicsmemory
A function returning a borrowed value
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() > b.len() { a } else { b } }
Struct holding a reference
struct Excerpt<'a> { part: &'a str, } impl<'a> Excerpt<'a> { fn announce(&self) -> &str { self.part } }
The static lifetime
let msg: &'static str = "baked into the binary";
Lifetime bound on a generic
fn spawn_task<T: Send + 'static>(value: T) { }
Two independent lifetimes
fn pick<'a, 'b>(a: &'a str, _b: &'b str) -> &'a str { a }
Elision Rulesmemory
RuleWhat the compiler assumes
1Every elided input reference gets its own distinct lifetime
2With exactly one input lifetime, the output gets that same one
3With a &self parameter, the output gets the lifetime of self
When Lifetimes Bitecareful
Returning a reference to a local
// fn bad() -> &str { // let s = String::from("oops"); // &s // error: s is dropped at the end of the function // } fn good() -> String { String::from("fine") }
Four ways out of a lifetime fight
// 1. return an owned String or Vec instead of a reference // 2. accept a &mut buffer the caller owns // 3. use Rc or Arc for shared ownership // 4. use Cow when the value is sometimes borrowed

Smart Pointers & Interior Mutability

When single ownership is not enough, these types move the checks to runtime while keeping the safety guarantees intact.

Pick the Right Wrapperstd
TypePurposeThreads
Box<T>single owner, value on the heapyes
Rc<T>shared ownership, reference countedno
Arc<T>shared ownership, atomic countyes
RefCell<T>mutate through a shared referenceno
Cell<T>same, for Copy types, no borrowsno
Mutex<T>exclusive access across threadsyes
RwLock<T>many readers or one writeryes
Cow<T>borrow until someone writesyes
Weak<T>non owning link, breaks cyclesboth
Boxmemory
Move a value to the heap
let boxed = Box::new(42); println!("{}", *boxed);
Store a trait object
let handler: Box<dyn Fn(&str)> = Box::new(|s| println!("{s}"));
Break a recursive type
struct Node { value: i32, next: Option<Box<Node>>, }
Rc, Arc, Weakmemory
Shared ownership in one thread
use std::rc::Rc; let shared = Rc::new(vec![1, 2, 3]); let a = Rc::clone(&shared); let b = Rc::clone(&shared); println!("{}", Rc::strong_count(&shared)); // 3
Shared ownership across threads
use std::sync::Arc; let config = Arc::new(Config::load()?); for _ in 0..4 { let c = Arc::clone(&config); std::thread::spawn(move || { use_config(&c); }); }
Weak references break reference cycles
use std::rc::{Rc, Weak}; struct Child { parent: Weak<Parent> } if let Some(p) = child.parent.upgrade() { /* still alive */ }
Interior Mutabilitycareful
RefCell moves borrow checking to runtime
use std::cell::RefCell; let cell = RefCell::new(vec![1, 2]); cell.borrow_mut().push(3); println!("{:?}", cell.borrow());
The classic shared mutable graph node
use std::{rc::Rc, cell::RefCell}; type Shared<T> = Rc<RefCell<T>>; let node: Shared<Vec<i32>> = Rc::new(RefCell::new(vec![])); node.borrow_mut().push(1);
Cell for simple Copy values
use std::cell::Cell; struct Counter { hits: Cell<u32> } impl Counter { fn record(&self) { self.hits.set(self.hits.get() + 1); } }
Cow, clone only when you actually write
use std::borrow::Cow; fn sanitize(input: &str) -> Cow<str> { if input.contains(' ') { Cow::Owned(input.replace(' ', "_")) } else { Cow::Borrowed(input) } }
Lazy one-time initialisation
use std::sync::OnceLock; static REGISTRY: OnceLock<Vec<String>> = OnceLock::new(); let r = REGISTRY.get_or_init(|| load_registry());
Deref & Dropstd
Deref makes a wrapper behave like its inner type
use std::ops::Deref; struct Wrapper(String); impl Deref for Wrapper { type Target = String; fn deref(&self) -> &String { &self.0 } } let w = Wrapper("hi".into()); println!("{}", w.len()); // String method through deref coercion
Drop for custom cleanup
impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); } }

Concurrency

Fearless concurrency is not a slogan. Send and Sync are checked at compile time, so a data race is a compile error rather than a production incident.

Threadscore
Spawn and join
use std::thread; let handle = thread::spawn(|| { heavy_work(); 42 }); let result = handle.join().unwrap();
Move captured data into the thread
let data = vec![1, 2, 3]; thread::spawn(move || println!("{data:?}")).join().unwrap();
Many threads, collect the handles
let handles: Vec<_> = (0..4) .map(|i| thread::spawn(move || i * i)) .collect(); let squares: Vec<i32> = handles.into_iter() .map(|h| h.join().unwrap()) .collect();
Scoped threads can borrow local data
let mut data = vec![1, 2, 3]; thread::scope(|s| { s.spawn(|| println!("{:?}", &data)); s.spawn(|| println!("len {}", data.len())); }); data.push(4); // safe, all scoped threads have finished
Shared Statecareful
Arc plus Mutex, the standard pairing
use std::sync::{Arc, Mutex}; let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..8 { let c = Arc::clone(&counter); handles.push(thread::spawn(move || { *c.lock().unwrap() += 1; })); } for h in handles { h.join().unwrap(); } println!("{}", *counter.lock().unwrap()); // 8
RwLock when reads dominate writes
use std::sync::RwLock; let cache = RwLock::new(HashMap::new()); { let read = cache.read().unwrap(); // many readers allowed } { let mut write = cache.write().unwrap(); // exclusive write.insert("k", "v"); }
Lock-free counters
use std::sync::atomic::{AtomicUsize, Ordering}; static HITS: AtomicUsize = AtomicUsize::new(0); HITS.fetch_add(1, Ordering::Relaxed); let n = HITS.load(Ordering::Relaxed);
Avoiding deadlock
// 1. always acquire multiple locks in the same order // 2. keep the critical section as short as possible // 3. never hold a lock across an .await point // 4. drop the guard explicitly before doing slow work
Channels & Parallel Iteratorsstd
Message passing with a channel
use std::sync::mpsc; let (tx, rx) = mpsc::channel(); for i in 0..3 { let tx = tx.clone(); thread::spawn(move || tx.send(i * 10).unwrap()); } drop(tx); // close the last sender so rx can end for received in rx { println!("{received}"); }
Bounded channel for backpressure
let (tx, rx) = mpsc::sync_channel(100); // blocks when full
Rayon, parallelism by changing one call
use rayon::prelude::*; let total: u64 = data.par_iter() .map(|item| expensive(item)) .sum();
Send and Sync in one line each
// Send: the type can be moved to another thread // Sync: &T can be shared with another thread // Rc is neither, Arc is both, Cell and RefCell are Send but not Sync

Async & Futures

An async fn returns a Future that does nothing until it is polled. Rust ships the syntax, the runtime comes from a crate such as Tokio.

Async Basicscore
Declare and await
async fn fetch(url: &str) -> Result<String, Error> { let body = reqwest::get(url).await?.text().await?; Ok(body) }
Async entry point with Tokio
#[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let body = fetch("https://example.com").await?; println!("{} bytes", body.len()); Ok(()) }
Async block
let task = async { let a = step_one().await; step_two(a).await };
Spawn a background task
let handle = tokio::spawn(async move { process(job).await }); let out = handle.await?;
Running Things Togetherstd
Wait for several futures at once
let (users, posts) = tokio::join!(fetch_users(), fetch_posts());
Take whichever finishes first
tokio::select! { result = fetch_primary() => handle(result), _ = tokio::time::sleep(Duration::from_secs(2)) => { println!("timed out"); } }
Fan out over a collection
use futures::future::join_all; let bodies = join_all(urls.iter().map(|u| fetch(u))).await;
Limit how many run at once
use futures::stream::{self, StreamExt}; let results: Vec<_> = stream::iter(urls) .map(|u| fetch(u)) .buffer_unordered(8) .collect() .await;
Timeouts and sleeps
use tokio::time::{timeout, sleep, Duration}; sleep(Duration::from_millis(250)).await; let out = timeout(Duration::from_secs(5), fetch(url)).await??;
Async Pitfallscareful
Never hold a std lock across an await
// wrong: the guard is held while the task is suspended // let g = mutex.lock().unwrap(); // do_io().await; // right: use the async-aware lock let g = tokio::sync::Mutex::lock(&mutex).await;
Move blocking work off the async threads
let out = tokio::task::spawn_blocking(|| { expensive_cpu_bound_work() }).await?;
Async in traits
// Rust 1.75 and later, inherent and static dispatch trait Store { async fn get(&self, id: u64) -> Option<Vec<u8>>; } // For trait objects, use the async-trait crate instead.
Box a future to name its type
use std::{future::Future, pin::Pin}; type BoxFut<T> = Pin<Box<dyn Future<Output = T> + Send>>;

Modules & Crates

Everything is private by default. Modules mirror the file tree, and pub is what opens each door one level at a time.

Declaring Modulescore
Inline module
mod math { pub fn add(a: i32, b: i32) -> i32 { a + b } fn secret() {} // private to this module } math::add(1, 2);
Modules from files
// src/main.rs mod math; // loads src/math.rs mod net; // loads src/net.rs or src/net/mod.rs // src/net.rs pub mod client; // loads src/net/client.rs
Visibility levels
pub fn everywhere() {} pub(crate) fn this_crate_only() {} pub(super) fn parent_module_only() {} pub(in crate::net) fn specific_path_only() {} fn private() {}
Struct fields need their own pub
pub struct Config { pub host: String, // readable outside port: u16, // still private }
use & Pathscore
Bring names into scope
use std::collections::HashMap; use std::io::{self, Read, Write}; use std::fmt::Result as FmtResult;
Relative path keywords
use crate::config::Settings; // from the crate root use super::helpers::clean; // from the parent module use self::inner::Thing; // from this module
Re-export to flatten your public API
// src/lib.rs mod internal; pub use internal::deeply::nested::Thing; // consumers write mycrate::Thing
Prelude module for convenience imports
pub mod prelude { pub use crate::{Client, Config, Error}; }
Crate Layoutstd
Conventional project tree
my-project/ ├── Cargo.toml ├── src/ │ ├── main.rs binary crate root │ ├── lib.rs library crate root │ └── bin/extra.rs a second binary ├── tests/ integration tests ├── benches/ benchmarks └── examples/ runnable examples
Workspace with several crates
# Cargo.toml at the repo root [workspace] members = ["core", "cli", "web"] resolver = "2"

Macros & Attributes

Macros run at compile time and expand into ordinary code. Attributes steer the compiler without changing what you wrote.

Standard Macrosstd
MacroWhat it does
println! print!write to stdout, with or without a newline
eprintln! eprint!write to stderr
format!build a String from a template
write! writeln!format into any Write target
vec!build a Vec from a list or a repeated value
panic!abort the current thread with a message
assert! assert_eq! assert_ne!runtime checks, kept in release builds
debug_assert!check compiled out of release builds
matches!pattern test that returns a bool
todo! unimplemented!placeholders that panic if reached
unreachable!marks a branch that cannot happen
dbg!print value with file and line, return it
include_str!embed a file as a &str at compile time
env! option_env!read an environment variable at build time
concat! stringify!join literals, turn tokens into a string
Writing macro_rules!pattern
A macro with a repeating argument list
macro_rules! my_vec { () => { Vec::new() }; ($($item:expr),+ $(,)?) => {{ let mut v = Vec::new(); $( v.push($item); )+ v }}; } let v = my_vec![1, 2, 3];
Fragment specifiers
// expr an expression ident a name // ty a type literal a literal // pat a pattern block a block // stmt a statement path a path // tt a single token tree
Export a macro from your crate
#[macro_export] macro_rules! log_here { ($msg:expr) => { println!("[{}:{}] {}", file!(), line!(), $msg) }; }
Inspect what a macro expands to
cargo install cargo-expand cargo expand path::to::module
Attributesstd
Conditional compilation
#[cfg(test)] mod tests { } #[cfg(target_os = "linux")] fn platform() -> &'static str { "linux" } #[cfg(feature = "json")] pub mod json;
Lints
#[allow(dead_code)] #[deny(missing_docs)] #[warn(clippy::all)] #![deny(unsafe_code)] // crate level, note the exclamation mark
Deprecation and inlining
#[deprecated(since = "0.4.0", note = "use connect_with instead")] pub fn connect() {} #[inline] #[inline(always)] fn hot_path() {}
Memory layout control
#[repr(C)] struct FfiPoint { x: f64, y: f64 } #[repr(transparent)] struct Meters(f64);
Must use, so callers cannot ignore a result
#[must_use = "this returns a new value, it does not modify in place"] fn normalized(&self) -> Vector { }

Testing & Documentation

The test runner ships with the toolchain. Unit tests live beside the code, integration tests get their own directory, and doc examples are compiled too.

Unit Testspattern
A test module at the bottom of the file
#[cfg(test)] mod tests { use super::*; #[test] fn adds_two_numbers() { assert_eq!(add(2, 2), 4); } }
Assertions with a message
assert!(result.is_ok(), "expected ok, got {result:?}"); assert_eq!(actual, expected); assert_ne!(id, 0);
Expect a panic
#[test] #[should_panic(expected = "divide by zero")] fn rejects_zero() { divide(1, 0); }
A test that returns Result
#[test] fn parses_config() -> Result<(), Box<dyn std::error::Error>> { let cfg: Config = toml::from_str("port = 80")?; assert_eq!(cfg.port, 80); Ok(()) }
Skip a slow test by default
#[test] #[ignore = "hits the network"] fn live_api() { }
Running Testsstd
Everyday commands
cargo test cargo test parses # only tests whose name contains parses cargo test -- --nocapture # show println output cargo test -- --test-threads=1 cargo test -- --ignored cargo test --doc
Integration test in tests/
// tests/api.rs use mycrate::Client; #[test] fn client_connects() { let c = Client::new("localhost"); assert!(c.ping().is_ok()); }
Async test with Tokio
#[tokio::test] async fn fetches_body() { let body = fetch("http://localhost:8080").await.unwrap(); assert!(!body.is_empty()); }
Documentationstd
Doc comment with a tested example
/// Adds two numbers. /// /// # Examples /// /// ``` /// use mycrate::add; /// assert_eq!(add(2, 2), 4); /// ``` pub fn add(a: i32, b: i32) -> i32 { a + b }
Build and open the docs
cargo doc --open cargo doc --no-deps --document-private-items
Benchmarks with criterion
// benches/parse.rs use criterion::{criterion_group, criterion_main, Criterion}; fn bench(c: &mut Criterion) { c.bench_function("parse", |b| b.iter(|| parse(INPUT))); } criterion_group!(benches, bench); criterion_main!(benches);

Unsafe & FFI

unsafe does not switch off the borrow checker. It unlocks five extra abilities and moves the responsibility for their invariants onto you.

What unsafe actually allows

Dereference a raw pointer.
Call an unsafe function or an external C function.
Access or modify a mutable static variable.
Implement an unsafe trait such as Send or Sync by hand.
Access the fields of a union.
Raw Pointerscareful
Create and dereference
let mut n = 10; let p: *const i32 = &n; let q: *mut i32 = &mut n; unsafe { println!("{}", *p); *q += 1; }
Unsafe function with a documented contract
/// # Safety /// `idx` must be less than `slice.len()`. unsafe fn get_unchecked(slice: &[i32], idx: usize) -> i32 { *slice.get_unchecked(idx) }
Wrap unsafe behind a safe API
pub fn split_two(v: &mut [i32], at: usize) -> (&mut [i32], &mut [i32]) { assert!(at <= v.len()); // the check that makes it sound unsafe { /* pointer arithmetic here */ } }
Calling Ccareful
Declare and call a C function
extern "C" { fn abs(input: i32) -> i32; } fn main() { unsafe { println!("{}", abs(-3)); } }
Expose a Rust function to C
#[no_mangle] pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b }
C strings across the boundary
use std::ffi::{CStr, CString}; use std::os::raw::c_char; let owned = CString::new("hello")?; let ptr: *const c_char = owned.as_ptr(); unsafe { let back = CStr::from_ptr(ptr).to_str()?; }
Tools that check unsafe code
cargo +nightly miri test # detects undefined behaviour cargo install cargo-geiger # counts unsafe usage in the tree

Cargo & Tooling

One tool builds, tests, formats, lints, documents, and publishes. These are the commands and manifest keys worth memorising.

Everyday Commandsstd
CommandWhat it does
cargo new namecreate a binary project
cargo new name --libcreate a library project
cargo initset up cargo in an existing folder
cargo buildcompile in debug mode
cargo build --releasecompile optimised
cargo run -- argsbuild and run, passing arguments
cargo checktype check without producing a binary
cargo testrun every test
cargo fmtformat the whole project
cargo clippyrun the lint suite
cargo doc --openbuild docs and open them
cargo add serdeadd a dependency
cargo remove serdedrop a dependency
cargo updaterefresh the lock file
cargo treeshow the dependency graph
cargo cleandelete the target directory
cargo benchrun benchmarks
cargo publishupload to crates.io
Cargo.tomlstd
A realistic manifest
[package] name = "my-service" version = "0.1.0" edition = "2021" rust-version = "1.75" license = "MIT" [dependencies] serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } anyhow = "1" [dev-dependencies] criterion = "0.5" [features] default = ["json"] json = ["dep:serde_json"]
Version requirement syntax
serde = "1.0.5" # >=1.0.5 and <2.0.0 serde = "~1.0.5" # >=1.0.5 and <1.1.0 serde = "=1.0.5" # exactly this version serde = "*" # any version, avoid this
Dependency from a path or git
core = { path = "../core" } lib = { git = "https://github.com/org/lib", tag = "v1.2.0" }
Release profile tuning
[profile.release] opt-level = 3 lto = true codegen-units = 1 strip = true panic = "abort"
Toolchainstd
Manage compiler versions
rustup update rustup toolchain install nightly rustup override set 1.75.0 rustup component add clippy rustfmt rustc --version
Pin the toolchain per project
# rust-toolchain.toml [toolchain] channel = "1.75.0" components = ["clippy", "rustfmt"]
Cross compile
rustup target add x86_64-unknown-linux-musl cargo build --release --target x86_64-unknown-linux-musl
Clippy in CI, warnings become errors
cargo clippy --all-targets --all-features -- -D warnings cargo fmt --check
Useful extra subcommands
cargo install cargo-watch cargo-audit cargo-nextest cargo watch -x check -x test cargo audit # known vulnerabilities cargo nextest run # faster test runner

IO & Ecosystem

The plumbing you reach for on day one: files, stdin, paths, environment, and the handful of crates that every real Rust project ends up using.

Reading & Writing Filesstd
Read a whole file
use std::fs; let text = fs::read_to_string("config.toml")?; let bytes = fs::read("image.png")?;
Write a whole file, creating or truncating it
fs::write("out.txt", "hello")?; fs::write("out.bin", &bytes)?;
Read line by line without loading the file
use std::fs::File; use std::io::{BufRead, BufReader}; let file = File::open("big.log")?; for line in BufReader::new(file).lines() { let line = line?; if line.contains("ERROR") { println!("{line}"); } }
Append to a file
use std::fs::OpenOptions; use std::io::Write; let mut file = OpenOptions::new() .append(true) .create(true) .open("audit.log")?; writeln!(file, "event at {}", stamp)?;
Buffered writing for many small writes
use std::io::BufWriter; let mut out = BufWriter::new(File::create("out.csv")?); for row in &rows { writeln!(out, "{},{}", row.id, row.name)?; } out.flush()?;
Standard Input & Outputstd
Read one line from the user
use std::io::{self, Write}; print!("name: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let name = input.trim();
Read all of stdin, for piped input
use std::io::Read; let mut buffer = String::new(); io::stdin().read_to_string(&mut buffer)?;
Loop over piped lines
use std::io::BufRead; for line in io::stdin().lock().lines() { println!("{}", line?.to_uppercase()); }
Paths & Directoriesstd
Build a path portably
use std::path::{Path, PathBuf}; let mut path = PathBuf::from("/var/data"); path.push("2026"); path.push("report.csv"); let borrowed = Path::new("relative/file.txt");
Inspect a path
path.exists(); path.is_file(); path.is_dir(); path.extension(); // Option<&OsStr> path.file_name(); path.file_stem(); path.parent(); path.to_str(); // Option<&str>, may fail on odd bytes
Directory operations
fs::create_dir_all("out/reports")?; fs::remove_file("tmp.txt")?; fs::remove_dir_all("build")?; fs::copy("a.txt", "b.txt")?; fs::rename("old.txt", "new.txt")?; fs::metadata("a.txt")?.len();
List a directory
for entry in fs::read_dir(".")? { let entry = entry?; println!("{}", entry.path().display()); }
Walk a tree recursively
use walkdir::WalkDir; for entry in WalkDir::new("src").into_iter().filter_map(|e| e.ok()) { if entry.path().extension().is_some_and(|e| e == "rs") { println!("{}", entry.path().display()); } }
Environment & Processstd
Command line arguments
use std::env; let args: Vec<String> = env::args().collect(); // args[0] is the program path let target = args.get(1).map(|s| s.as_str()).unwrap_or("default");
Environment variables
let home = env::var("HOME").unwrap_or_default(); let port: u16 = env::var("PORT").ok() .and_then(|v| v.parse().ok()) .unwrap_or(8080); env::set_var("RUST_LOG", "debug");
Exit with a status code
if failed { std::process::exit(1); }
Run an external command
use std::process::Command; let output = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output()?; let sha = String::from_utf8_lossy(&output.stdout);
CLI Parsing with clapcrate
Derive a full command line interface
use clap::Parser; #[derive(Parser)] #[command(name = "tool", version, about = "Does a thing")] struct Args { /// File to process input: String, /// Output path #[arg(short, long, default_value = "out.txt")] output: String, /// Print more detail #[arg(short, long)] verbose: bool, } fn main() { let args = Args::parse(); println!("{} to {}", args.input, args.output); }
Subcommands
#[derive(clap::Subcommand)] enum Action { Build { release: bool }, Clean, } #[derive(Parser)] struct Args { #[command(subcommand)] action: Action, }
JSON with serdecrate
Struct to JSON and back
use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize, Debug)] struct User { id: u64, name: String, active: bool } let json = serde_json::to_string(&user)?; let pretty = serde_json::to_string_pretty(&user)?; let parsed: User = serde_json::from_str(&json)?;
Untyped JSON when the shape is unknown
use serde_json::Value; let v: Value = serde_json::from_str(body)?; let name = v["user"]["name"].as_str().unwrap_or("unknown"); let count = v["items"].as_array().map(|a| a.len()).unwrap_or(0);
Field attributes you will actually use
#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Payload { #[serde(rename = "userId")] user_id: u64, #[serde(default)] retries: u32, #[serde(skip_serializing_if = "Option::is_none")] note: Option<String>, }
Build JSON inline
use serde_json::json; let body = json!({ "id": 7, "tags": ["a", "b"], "nested": { "ok": true } });
Regexcrate
Match, find, and replace
use regex::Regex; let re = Regex::new(r"(\d{4})-(\d{2})-(\d{2})")?; re.is_match("due 2026-08-19"); let caps = re.captures("due 2026-08-19").unwrap(); let year = &caps[1]; let masked = re.replace_all(text, "REDACTED");
Compile once, use everywhere
use std::sync::LazyLock; static EMAIL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\w.+-]+@[\w-]+\.[\w.]+$").unwrap()); EMAIL.is_match(input);
Named capture groups
let re = Regex::new(r"(?P<key>\w+)=(?P<value>\S+)")?; if let Some(c) = re.captures("mode=fast") { println!("{} is {}", &c["key"], &c["value"]); }
Time & Datesstd
Measure how long something took
use std::time::Instant; let started = Instant::now(); do_work(); println!("took {:?}", started.elapsed());
Unix timestamp from the standard library
use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH)? .as_secs();
Calendar dates with chrono
use chrono::{Utc, Local, NaiveDate, Duration}; let now = Utc::now(); println!("{}", now.format("%Y-%m-%d %H:%M:%S")); let d = NaiveDate::parse_from_str("2026-08-19", "%Y-%m-%d")?; let next_week = now + Duration::days(7);
Logging & HTTPcrate
Structured logging with tracing
use tracing::{info, warn, error, debug}; tracing_subscriber::fmt::init(); info!(user_id = 7, "request handled"); warn!("retrying in {}s", delay); error!(?err, "upload failed");
Instrument a function with a span
#[tracing::instrument(skip(db))] async fn load_user(db: &Pool, id: u64) -> Result<User> { }
HTTP requests with reqwest
let body = reqwest::get("https://api.example.com/health") .await? .text() .await?; let user: User = client .post("https://api.example.com/users") .json(&payload) .send() .await? .error_for_status()? .json() .await?;
Crates worth knowing by name
serde + serde_json serialization tokio async runtime axum / actix-web web servers reqwest http client clap command line parsing anyhow / thiserror error handling tracing logging and spans rayon data parallelism sqlx / diesel databases regex regular expressions chrono / time dates and times itertools extra iterator adapters

Compiler Error Index

The errors that actually stop people, each with the smallest reproduction and the fix. Run rustc --explain E0382 for the official long form.

Ownership & Borrowing Errorsmemory
E0382 borrow of moved value
let s = String::from("hi"); let t = s; println!("{s}"); // error[E0382] // fix: clone, or borrow instead of moving let t = s.clone(); let t = &s;
E0502 cannot borrow as mutable because it is also borrowed as immutable
let mut v = vec![1, 2, 3]; let first = &v[0]; v.push(4); // error[E0502] println!("{first}"); // fix: finish using the borrow before mutating let first = v[0]; // copy the value out v.push(4);
E0499 cannot borrow as mutable more than once
let mut v = vec![1, 2]; let a = &mut v[0]; let b = &mut v[1]; // error[E0499] // fix: split the slice into disjoint halves let (left, right) = v.split_at_mut(1);
E0505 cannot move out of a value because it is borrowed
let v = vec![1, 2]; let r = &v; drop(v); // error[E0505] println!("{r:?}"); // fix: let the borrow end first, then move
E0507 cannot move out of borrowed content
fn take(user: &User) -> String { user.name // error[E0507] } // fix: clone the field, or take self by value fn take(user: &User) -> String { user.name.clone() } fn into_name(user: User) -> String { user.name }
E0596 cannot borrow as mutable
let v = vec![1]; v.push(2); // error[E0596] // fix: declare the binding mutable let mut v = vec![1];
E0384 cannot assign twice to an immutable variable
let x = 5; x = 6; // error[E0384] // fix: let mut x = 5; or shadow with a new let
Type & Trait Errorscore
E0308 mismatched types
fn f() -> i32 { "hello" } // error[E0308] // most common real cause: a stray semicolon fn g() -> i32 { 1 + 1; } // returns () not i32
E0308 expected &str found String, or the reverse
fn greet(name: &str) {} greet(owned_string); // error[E0308] // fix: borrow it greet(&owned_string); greet(owned_string.as_str());
E0277 the trait bound is not satisfied
#[derive(Debug)] // add the missing derive struct Id(u64); println!("{:?}", id); // needs Debug // as a HashMap key you need both #[derive(PartialEq, Eq, Hash)] struct Key(String);
E0599 no method named X found
// usually a missing trait import use std::io::Write; // needed for .write_all() use std::io::BufRead; // needed for .lines() use itertools::Itertools; // needed for .sorted()
E0282 type annotations needed
let v = Vec::new(); // error[E0282] let n = "5".parse(); // error[E0282] // fix: say what you want let v: Vec<i32> = Vec::new(); let n = "5".parse::<i32>()?;
E0433 failed to resolve, use of undeclared crate or module
// the crate is missing from Cargo.toml cargo add serde_json // or the path is wrong use crate::config::Settings; // not just config::Settings
E0507 and E0515 when returning references
fn bad() -> &str { let s = String::from("x"); &s // error[E0515] } // fix: return the owned value fn good() -> String { String::from("x") }
Lifetime & Async Errorscareful
E0106 missing lifetime specifier
struct Holder { text: &str } // error[E0106] // fix: name the lifetime struct Holder<'a> { text: &'a str } // or own the data struct Holder { text: String }
E0597 borrowed value does not live long enough
let r; { let x = 5; r = &x; // error[E0597] } println!("{r}"); // fix: widen the scope of x, or store the value not a reference
Future is not Send
// cause: a std MutexGuard or Rc held across an .await let guard = mutex.lock().unwrap(); do_io().await; // guard is still alive here // fix: drop it first, or use tokio::sync::Mutex { let guard = mutex.lock().unwrap(); use_it(&guard); } do_io().await;
Cannot return value referencing local data
// happens a lot with iterators over a local String fn words(input: String) -> impl Iterator<Item = &str> { } // error // fix: take a borrowed parameter instead fn words(input: &str) -> impl Iterator<Item = &str> { input.split_whitespace() }
Reading Errors Fasterworkflow
Get the full explanation of any error code
rustc --explain E0382 cargo check # fastest way to see errors cargo clippy --fix # apply the mechanical fixes cargo fix --edition # migrate to a newer edition
Read errors bottom up
// 1. the error line says WHAT is wrong // 2. the caret span says WHERE // 3. the note lines say WHY // 4. the help line usually contains the exact fix

Coming From Another Language

Direct translations for the things you already know how to do. Find your habit on the left, learn its Rust spelling on the right.

Python to Rusttranslation
PythonRust
x = 5let x = 5;
listVec<T>
dictHashMap<K, V>
setHashSet<T>
tuple(A, B) or a struct
NoneOption::None
len(x)x.len()
for i, v in enumerate(xs)for (i, v) in xs.iter().enumerate()
[f(x) for x in xs]xs.iter().map(f).collect()
[x for x in xs if p(x)]xs.iter().filter(|x| p(x)).collect()
f"{name} is {age}"format!("{name} is {age}")
try / exceptResult and the ? operator
raise ValueErrorreturn Err(...)
with open(p) as flet f = File::open(p)?; (dropped at scope end)
classstruct plus impl
@dataclass#[derive(Debug, Clone, PartialEq)]
abstract base classtrait
json.loadsserde_json::from_str
pip installcargo add
requirements.txtCargo.toml
pytestcargo test
JavaScript & TypeScript to Rusttranslation
JavaScriptRust
const x = 5let x = 5;
let x = 5let mut x = 5;
ArrayVec<T>
Object / Mapstruct or HashMap<K, V>
null / undefinedOption<T>
arr.map(f)arr.iter().map(f).collect()
arr.filter(p)arr.iter().filter(p).collect()
arr.reduce(f, init)arr.iter().fold(init, f)
arr.find(p)arr.iter().find(p)
arr.includes(x)arr.contains(&x)
`${a} and ${b}`format!("{a} and {b}")
x?.y ?? zx.map(|v| v.y).unwrap_or(z)
try / catchResult and the ? operator
async / awaitasync / .await plus a runtime
Promise.alltokio::join! or join_all
interfacetrait
type union A | Benum with variants
JSON.parseserde_json::from_str
npm installcargo add
package.jsonCargo.toml
node_modulestarget and the cargo registry cache
Go to Rusttranslation
GoRust
x := 5let x = 5;
[]TVec<T>
map[K]VHashMap<K, V>
nilOption::None
if err != nil { return err }the ? operator
errors.New / fmt.Errorfthiserror or anyhow
interfacetrait
struct embeddingcomposition plus trait impls
go func()thread::spawn or tokio::spawn
chan Tmpsc::channel or tokio::sync::mpsc
sync.MutexMutex<T>, which wraps the data
deferDrop, which runs at scope end
panic / recoverpanic! (no recover, use Result)
go.modCargo.toml
go test ./...cargo test
gofmtcargo fmt
C and C++ to Rusttranslation
C or C++Rust
malloc / freeownership, freed automatically at scope end
T*&T, &mut T, or Box<T>
std::unique_ptrBox<T>
std::shared_ptrRc<T> or Arc<T>
std::weak_ptrWeak<T>
std::vectorVec<T>
std::unordered_mapHashMap<K, V>
std::optionalOption<T>
std::variantenum with data
templategenerics with trait bounds
virtual methoddyn Trait
RAII destructorthe Drop trait
const correctnessimmutable by default
header filesmodules, no separate declarations
make / cmakecargo
undefined behaviourimpossible in safe Rust
Habits to Unlearnmindset
Stop reaching for a class hierarchy
// there is no inheritance in Rust // compose data with structs, share behaviour with traits trait Storage { fn save(&self, key: &str, value: &[u8]); } struct DiskStorage { root: PathBuf } struct MemoryStorage { map: HashMap<String, Vec<u8>> }
Stop returning a sentinel value
// not -1, not empty string, not null fn find(&self, id: u64) -> Option<&User> fn load(&self, path: &str) -> Result<Config, ConfigError>
Stop mutating shared state by default
// take the input, return a new value fn normalized(input: &str) -> String // mutate in place only when the signature says so fn normalize(input: &mut String)
Cloning while learning is fine
// this is not a crime, it is a stepping stone let name = user.name.clone(); // optimise the clone away later, once the design settles // and only if a profiler says it matters
Let the type system carry the rules
// instead of validating a String everywhere struct Email(String); impl Email { fn parse(raw: &str) -> Result<Self, InvalidEmail> { } } // now anything holding an Email is known to be valid

What Rust Is

Rust is a compiled systems programming language built around one idea: memory safety without a garbage collector. The compiler proves that your references are valid before the program ever runs, so an entire class of bugs never reaches production.

Graydon Hoare started it as a personal project in 2006, Mozilla sponsored it from 2009, and version 1.0 shipped in May 2015. The Rust Foundation has stewarded the language since 2021, with members including AWS, Google, Microsoft, and Huawei.

It now runs in the Linux kernel, in Windows, in Android, in Firefox, in Cloudflare's edge network, and inside AWS Firecracker. Developers have voted it the most admired language in the Stack Overflow survey every year since 2016.

The trade is simple. You spend more time convincing the compiler up front, and in exchange you get C-level performance with none of the null pointer dereferences, use-after-free bugs, buffer overflows, or data races.


Rust Syntax Basics

Rust reads like a curly-brace language with a functional accent. Most constructs are expressions, semicolons matter, and types appear after the name.

Variables and mutability

Every binding is immutable unless you write mut. This is the reverse of most languages and it is deliberate: shared mutable state is where concurrency bugs come from.

let x = 5;          // immutable
let mut y = 10;     // mutable
y += 1;

const MAX: u32 = 100_000;   // compile-time constant, type required

Shadowing lets you reuse a name with a different type, which is common when parsing input.

let input = "42";
let input: i32 = input.parse().unwrap();

Functions and expressions

A function body's last expression is its return value when you leave off the semicolon. Adding that semicolon turns the expression into a statement that evaluates to unit, which is the single most common compile error for newcomers.

fn add(a: i32, b: i32) -> i32 {
    a + b       // returned
}

fn add_wrong(a: i32, b: i32) -> i32 {
    a + b;      // error: expected i32, found ()
}

Control flow

There is no ternary operator because if is already an expression. Both branches must have the same type.

let label = if n % 2 == 0 { "even" } else { "odd" };

for i in 0..5 { }           // 0 to 4
for item in &collection { } // borrow each item
while let Some(x) = stack.pop() { }

Ownership, Borrowing, and Lifetimes

This is the part that makes Rust different from every mainstream language, and it is the part worth understanding properly before anything else.

The ownership model

Three rules cover it. Each value has exactly one owner. There is only one owner at a time. When the owner goes out of scope, the value is dropped and its memory is released.

let a = String::from("hello");
let b = a;              // ownership moves to b
// println!("{a}");     // error: value borrowed after move

Nothing was copied there. The pointer, length, and capacity moved into b, and a was invalidated so the same heap buffer can never be freed twice. Types that are cheap to duplicate, such as integers and bool, implement Copy and are duplicated instead of moved.

Borrowing

Passing ownership everywhere would be exhausting, so you borrow instead. A borrow is a reference that does not take ownership.

fn length(s: &String) -> usize { s.len() }   // shared borrow
fn shout(s: &mut String) { s.push_str("!"); } // exclusive borrow

The rule is short: you may have any number of shared references, or exactly one mutable reference, never both at once. That single invariant is what eliminates data races at compile time.

Lifetimes

Lifetime annotations describe how long references remain valid. They never change runtime behaviour, they only give the compiler enough information to check what you already meant.

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

Thanks to elision rules, most functions need no annotations at all. You mainly write them when a function returns a reference derived from more than one input, or when a struct stores a reference.


Types and Data Modelling

Rust models data with structs for "this and that" and enums for "this or that". Enums carry data, which makes them far more useful than in C or Java.

Structs

struct User { name: String, age: u32 }

impl User {
    fn new(name: &str) -> Self {
        Self { name: name.to_string(), age: 0 }
    }
    fn birthday(&mut self) { self.age += 1; }
}

Enums and pattern matching

enum Shape {
    Circle(f64),
    Rect { w: f64, h: f64 },
}

let area = match shape {
    Shape::Circle(r) => 3.14159 * r * r,
    Shape::Rect { w, h } => w * h,
};

match is exhaustive. If you add a variant later, every match on that enum stops compiling until you handle the new case. That is not an inconvenience, it is a refactoring safety net.

No null, no exceptions

Rust has neither. Absence is modelled by Option<T> and failure by Result<T, E>, both ordinary enums from the standard library. The compiler forces you to acknowledge both possibilities.

SituationOther languagesRust
missing valuenull, nil, None, undefinedOption::None
operation failedthrow, panic, error returnResult::Err
ignore the failureempty catch block.ok() or .unwrap_or()
propagate upwardrethrowthe ? operator
truly unrecoverablefatal exceptionpanic!

Error Handling in Practice

The ? operator is the workhorse. It unwraps on the success path and returns early on the failure path, converting the error type through the From trait as it goes.

fn load(path: &str) -> Result<Config, ConfigError> {
    let text = std::fs::read_to_string(path)?;
    let cfg: Config = toml::from_str(&text)?;
    cfg.validate()?;
    Ok(cfg)
}

Two crates dominate real projects. Use thiserror in libraries, where callers need to match on specific error variants. Use anyhow in applications, where you mostly want context strings and a readable report at the top level.

Reserve unwrap() for tests, prototypes, and cases where a failure genuinely means a bug in your own code. In anything long lived, prefer expect() with a message that explains the violated assumption.


Traits, Generics, and Zero-Cost Abstraction

Traits are Rust's answer to interfaces, and generics are how one function serves many types. Together they give abstraction that costs nothing at runtime.

trait Area {
    fn area(&self) -> f64;
    fn describe(&self) -> String {
        format!("area is {:.2}", self.area())
    }
}

fn total<T: Area>(items: &[T]) -> f64 {
    items.iter().map(|i| i.area()).sum()
}

When you call a generic function, the compiler generates a specialised copy for each concrete type used. This is called monomorphisation, and it means the generic version runs exactly as fast as a hand-written one.

When you need different concrete types in the same collection, switch to a trait object with Box<dyn Trait>. That costs one pointer indirection per call and prevents inlining, so use it when you need the flexibility rather than by default.

Iterators

Iterator chains are lazy and compile down to the same machine code as an equivalent loop. Nothing executes until a consuming adapter such as collect, sum, or for pulls values through.

let names: Vec<String> = users
    .iter()
    .filter(|u| u.active)
    .map(|u| u.name.to_uppercase())
    .collect();

Concurrency Without Data Races

Two marker traits carry the whole model. Send means a value can be moved to another thread. Sync means a reference to it can be shared with another thread. The compiler derives both automatically and rejects any code that would violate them.

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..8 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || { *c.lock().unwrap() += 1; }));
}
for h in handles { h.join().unwrap(); }

Try to share a plain Rc between threads and the code will not compile, because Rc is not Send. The mistake is caught at build time rather than as an intermittent production failure.

For asynchronous work, Rust ships the async and await syntax but no runtime. Tokio is the default choice for network services, and async-std or smol serve narrower needs.


Cargo, the Reason Rust Tooling Feels Good

Cargo is the build system, package manager, test runner, documentation generator, and publisher in one binary. There is no separate build file format to learn and no plugin ecosystem to assemble.

CommandWhat it does
cargo new appscaffold a new project
cargo checktype check quickly without linking
cargo build --releaseproduce an optimised binary
cargo testrun unit, integration, and doc tests
cargo fmtapply the standard formatting
cargo clippyrun more than 700 lints
cargo add tokioadd a dependency to Cargo.toml
cargo doc --openbuild and read the API docs

Doc comments are written in Markdown, and any fenced code block inside them is compiled and run by cargo test, so your examples cannot silently rot.

cargo check deserves a special mention. It performs the full type and borrow analysis without generating machine code, so it returns in a fraction of the time of a build. Most Rust developers keep it running on save.


When Rust Is the Right Choice

Rust is not a universal replacement. It earns its keep in specific situations.

  • Systems and infrastructure: operating system components, drivers, embedded firmware, databases, and network proxies where predictable latency matters.
  • Performance-critical services: request handlers, parsers, and pipelines where garbage collection pauses are unacceptable.
  • WebAssembly: Rust produces some of the smallest and fastest wasm modules, which is why it dominates that space.
  • CLI tools: single static binaries with no runtime dependency, which is why ripgrep, fd, bat, and uv are written in Rust.
  • Replacing C or C++ selectively: rewriting the memory-unsafe core of an existing codebase while leaving the rest in place.

The surrounding toolchain is ordinary. Pattern matching on text uses the regex crate with the syntax in our regex cheat sheet, database work with sqlx expects the same SQL you already write, and a release binary drops into a scratch Docker image with no runtime to install.

It is a weaker fit for quick data exploration scripts, for teams with no appetite for a learning curve, or for problems where an existing library ecosystem in another language already solves everything.

How Rust compares

AspectRustGoC++
memory managementownership, compile timegarbage collectormanual
runtime pausesnoneshort GC pausesnone
memory safetyguaranteed in safe codeguaranteed except racesprogrammer's job
learning curvesteepgentlesteep
compile speedslowvery fastslow
error modelResult and Optionerror return valuesexceptions
package managerCargo, built ingo modules, built invaries by project

Learning Path That Actually Works

The order matters more than the material, because the borrow checker punishes anyone who skips ahead.

  • Week one: syntax, ownership, borrowing, and the standard collections. Do not fight lifetimes yet, and clone freely while you learn.
  • Week two: enums, pattern matching, Option, Result, and the ? operator. Build a small command line tool.
  • Week three: traits, generics, and iterators. This is where the language starts to feel productive rather than restrictive.
  • Week four: lifetimes properly, smart pointers, and then either threads or async depending on what you are building.
  • Ongoing: read the compiler errors slowly. Rust's diagnostics usually name the exact fix, which is a genuine advantage over most toolchains.

If you are arriving from another stack, the fastest way in is to translate what you already do. Our Python cheat sheet and JavaScript cheat sheet line up well against the translation tables in the tool above, and the Git cheat sheet covers the version control side of any Rust workspace.

The canonical free resources are The Rust Programming Language, Rust by Example, and Rustlings for hands-on exercises. Crate documentation lives on docs.rs, and packages are published to crates.io.


Common Beginner Mistakes

  • Fighting the borrow checker instead of restructuring: if a function needs a value in two places, the answer is usually a different data layout, not a cleverer set of references.
  • Reaching for Rc and RefCell too early: they are tools for genuine shared ownership, not a way to silence the compiler.
  • Using String where &str would do: take &str in parameters and return String only when the caller needs ownership.
  • Sprinkling unwrap through production code: every unwrap is a potential panic with no context attached.
  • Ignoring clippy: it catches non-idiomatic patterns that compile fine but read badly, and it explains each one.
  • Writing unsafe to avoid a design problem: unsafe should be a small, documented, audited core, never a shortcut.

Frequently Asked Questions

Is Rust hard to learn?

The syntax is ordinary. The difficulty is the ownership model, which has no equivalent in most languages, so experienced developers often feel like beginners for two or three weeks. Once ownership clicks, the rest of the language is unusually consistent and the compiler stops arguing with you.

Is Rust faster than C?

They are in the same performance class, and benchmark results depend far more on the specific program than on the language. Rust reaches that speed with bounds checks and safety guarantees that C leaves to the programmer, and the optimiser removes most of those checks when it can prove they are redundant.

Do I need to understand lifetimes to be productive?

Not at first. Lifetime elision handles the majority of function signatures automatically, and cloning a value is an acceptable way to move forward while learning. You will need them properly once you write structs that hold references or libraries with borrowed return types.

What is the difference between panic and Result?

Result is for failures the caller could reasonably handle, such as a missing file or invalid input. Panic is for situations where a program invariant has been broken and continuing would be wrong, such as indexing past the end of a slice. Libraries should almost always return Result and let the application decide.

Why is my Rust build so slow?

Generic monomorphisation, link-time optimisation, and large dependency trees are the usual causes. Use cargo check during development instead of cargo build, keep debug builds unoptimised, and consider a faster linker such as lld or mold for large projects.

Is Rust ready for web backends?

Yes. Axum, Actix Web, and Rocket are mature frameworks, sqlx and Diesel cover database access with compile-time checked queries, and serde handles serialization. The main cost is development speed compared with a dynamic language, not capability.