Learn Rust in a Single Post: A Complete Rust Tutorial from Ownership to Async
Rust’s pitch is unusual: a systems language with no garbage collector, no runtime, and memory safety enforced at compile time by the borrow checker. The result is a language that is as fast as C++ but where entire categories of bugs — null dereferences, use-after-free, data races — are compile errors, not runtime crashes.
This post teaches the whole language in one go, in five stages, with runnable snippets. The goal: by the end you understand ownership, borrowing, lifetimes, traits, generics, async, and the cargo toolchain, and you know what to learn next.
We target the current stable edition (Rust 2021 / 2024). Everything here runs on stable.
The Roadmap
Rust has one famously hard concept — ownership — and the rest of the language is arranged around it. Learn the stages in order and the difficulty lands in exactly one place.
- Fundamentals — variables,
mut, types, control flow,Stringvs&str - Ownership + Borrow — the borrow checker, references, lifetimes, slices
- Structs + Enums — structs,
impl, enums, pattern matching,OptionandResult - Traits + Generics — traits, static vs dynamic dispatch, iterators
- Async + Ecosystem —
async/await, threads, channels, cargo, error handling
Stage 1 — Fundamentals
A program
fn main() {
println!("Hello, Rust!");
}
println! is a macro (note the !), not a function. Macros generate code at compile time; you’ll meet vec!, format!, and panic! too.
Variables, mut, shadowing
let x = 5; // immutable by default
// x = 6; // ERROR: cannot assign to immutable
let mut y = 10; // mutable
y = 20;
let z = 5;
let z = z + 1; // shadowing: re-declare with same name, new type allowed
let z = "now a str"; // type can even change on shadow
Immutability-by-default is a real default, not a suggestion. Opt into mutation explicitly with mut. Shadowing lets you rebind a name in the same scope — handy for transforming a value without inventing throwaway names.
Primitive types
let a: i32 = -5; // signed 32-bit
let b: u64 = 5; // unsigned 64-bit
let f: f64 = 3.14; // 64-bit float
let t: bool = true;
let c: char = '🦀'; // 4-byte Unicode scalar
let pair: (i32, f64) = (1, 2.0);
let (i, _) = pair; // destructuring; _ ignores
let arr: [i32; 3] = [1, 2, 3]; // fixed-size array
let v: Vec<i32> = vec![1, 2, 3]; // growable
Control flow
if x > 0 { /* ... */ } else { /* ... */ }
for i in 0..10 { /* 0..9 */ } // exclusive range
for i in 0..=10 { /* 0..10 */ } // inclusive
while cond { /* ... */ }
loop { break; } // infinite until break
match x {
0 => "zero",
1 | 2 => "one or two",
3..=9 => "single digit",
n if n % 2 == 0 => "even",
_ => "other",
};
match is exhaustive — the compiler forces you to handle every case (or add _ for “the rest”). This is why Option and Result are safe: you cannot forget the None/Err case.
Functions
fn add(a: i32, b: i32) -> i32 { a + b } // last expression, no semicolon = return
fn bump(out: &mut i32) { *out += 1; } // mutable reference
// Closures (lambdas)
let sq = |x: i32| x * x; // inferred, captured env
let bias = 10;
let add_bias = move |x| x + bias; // move captures by value
A block’s last expression has no semicolon and becomes the block’s value. A stray semicolon turns an expression into a statement that returns () — the source of many “expected i32, found ()” errors.
String vs &str
This trips newcomers. String is an owned, growable, heap-allocated UTF-8 buffer. &str is a borrowed view onto UTF-8 bytes — a fat pointer (ptr, len) that may point into a String, a string literal, or a file.
let owned: String = String::from("hello");
let view: &str = "world"; // string literal: &'static str
let view2: &str = &owned; // borrow the String as &str
Rule: own a String when you need to grow, store, or return text; take a &str when you just read it. A function that takes &str accepts String (via deref coercion), literals, and slices alike.
Stage 2 — Ownership and Borrowing
This is the heart of Rust. Get this stage and the rest of the language is straightforward.
Ownership
Every value has exactly one owner. When the owner goes out of scope, the value is dropped (its memory freed). There is no garbage collector and no free() — the compiler inserts the drop at the closing brace.
{
let s = String::from("hi"); // s owns the heap buffer
} // s dropped here -> buffer freed
Move semantics
For heap-owning types, assignment moves ownership rather than copying. After a move, the old variable is invalid — the compiler refuses to let you use it:
let a = String::from("hi");
let b = a; // a moved into b
// println!("{}", a); // ERROR: a was moved
println!("{}", b); // OK
This is how Rust prevents double-free without a copy: there is always exactly one owner, so exactly one drop.
Copy types
Small, fixed-size types that are cheap to copy bitwise implement the Copy trait. For Copy types, assignment copies instead of moving:
let x = 5;
let y = x; // i32 is Copy: x still valid
println!("{} {}", x, y);
i32, f64, bool, char, tuples of Copy types, and raw arrays of Copy types are Copy. String, Vec<T>, Box<T> are not — they own heap and must move.
Borrowing
Instead of transferring ownership, you can borrow with references:
&T— a shared reference. Many readers, no mutation. The thing borrowed is not moved.&mut T— a mutable reference. One writer, exclusive — no other reference may exist while it lives.
let s = String::from("hi");
let r1 = &s; // shared borrow
let r2 = &s; // another shared borrow: OK
println!("{} {}", r1, r2);
let mut m = String::from("hi");
let mr = &mut m; // exclusive mutable borrow
mr.push_str("!");
// let other = &m; // ERROR: cannot borrow `m` immutably while `mr` lives
println!("{}", mr);
The aliasing rule
The borrow checker enforces one rule with teeth: you may have many &T, or one &mut T, but never both at the same time. This single rule eliminates data races at compile time: you cannot have a reader and a writer of the same memory simultaneously.
Slices
A slice is a borrowed view into a contiguous sequence — &[T] for arrays/vectors, &str for strings. Slices decouple “I want to read some elements” from “what container owns them”:
fn sum(slice: &[i32]) -> i32 { slice.iter().sum() }
let v = vec![1, 2, 3, 4];
sum(&v); // whole vector
sum(&v[0..2]); // first two
Lifetimes
A lifetime is a compile-time proof that a reference does not outlive the data it points to. Most of the time the compiler infers them. You write them when a function returns a reference and the compiler cannot tell which input the output borrows from:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a says: “the returned reference is valid for as long as both inputs are.” The compiler then checks every call site — you cannot keep the return value past the death of its owners. 'static is the special lifetime that lasts the whole program (string literals are &'static str).
When you get a “does not live long enough” error, the borrow checker has found a real dangling reference. The fix is usually to restructure so the owner lives longer than the borrow, or to return an owned value instead of a reference.
Stage 3 — Structs, Enums, and Pattern Matching
Structs and impl
struct Counter { count: i32 }
impl Counter {
fn new() -> Self { Counter { count: 0 } } // associated function (no self)
fn inc(&mut self) { self.count += 1; } // method (&mut self)
fn get(&self) -> i32 { self.count } // method (&self)
fn into_inner(self) -> i32 { self.count } // consumes self
}
let mut c = Counter::new();
c.inc();
println!("{}", c.get());
Self is the type being implemented. Methods take self, &self, or &mut self (or self by value to consume). Associated functions without self are called like Counter::new().
Enums and pattern matching
Rust enums are algebraic data types — each variant can carry different data. Pattern matching destructures them exhaustively:
enum Shape {
Circle(f64),
Rect(f64, f64),
Point { x: f64, y: f64 },
}
fn area(s: Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rect(w, h) => w * h,
Shape::Point { .. } => 0.0, // .. ignores remaining fields
}
}
The compiler checks that match covers every variant. Add a variant later and every match won’t compile until you handle it — that is refactor-safety at the type level.
Option and Result: no null, no exceptions
Rust has no null and no exceptions. Instead:
Option<T>=Some(T)orNone— “a value that may be absent”Result<T, E>=Ok(T)orErr(E)— “a computation that may fail”
fn parse(s: &str) -> Result<i32, std::num::ParseIntError> {
s.parse::<i32>()
}
match parse("42") {
Ok(n) => println!("got {}", n),
Err(e) => println!("error: {}", e),
}
// The ? operator: return Err early, unwrap Ok
fn double(s: &str) -> Result<i32, std::num::ParseIntError> {
let n = parse(s)?; // if Err, return Err; if Ok, bind n
Ok(n * 2)
}
? is the idiomatic error-propagation operator — it turns the four-line “match and return early” into one suffix. You will use it constantly.
Stage 4 — Traits and Generics
Traits
A trait is an interface — a set of methods a type can implement. Traits have default methods and can require associated types:
trait Animal {
fn name(&self) -> &str;
fn sound(&self) -> &str { "..." } // default
}
struct Dog { name: String }
impl Animal for Dog {
fn name(&self) -> &str { &self.name }
fn sound(&self) -> &str { "woof" } // override default
}
Common traits you’ll implement constantly: Debug ({:?}), Display ({}), Clone, PartialEq/Eq, Hash, Default. Derive them when you can:
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }
Generics and trait bounds
fn max<T: PartialOrd>(a: T, b: T) -> T { // T must be orderable
if a > b { a } else { b }
}
// where clauses read better with many bounds
fn sum<T>(items: &[T]) -> T
where T: Default + std::ops::Add<Output = T> + Copy
{
items.iter().fold(T::default(), |acc, x| acc + *x)
}
Generic code is monomorphized — the compiler generates a specialized version for each concrete type used. The result is zero-cost: a generic max<i32> and a hand-written max_i32 compile to the same machine code.
Static vs dynamic dispatch
- Static dispatch (generics,
impl Trait) — the compiler monomorphizes; fastest, no runtime overhead, but one copy per type. - Dynamic dispatch (
dyn Trait) — a trait object with a vtable; one code path, small indirection cost, enables heterogeneous collections.
// static
fn draw_static<T: Draw>(x: &T) { x.draw(); }
// dynamic: a box of "something that implements Draw"
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Square { s: 2.0 }),
];
for s in &shapes { s.draw(); } // dispatches via vtable
Default to generics; reach for dyn Trait when you need a collection of different concrete types or to erase types across an API boundary.
Iterators
Iterators are lazy and zero-cost — the combinators desugar to the same loops you’d write by hand:
let v = vec![1, 2, 3, 4, 5];
let sum: i32 = v.iter()
.filter(|x| *x % 2 == 0)
.map(|x| x * x)
.sum(); // 4 + 16 = 20
// for loops use IntoIterator
for x in &v { /* x is &i32 */ }
for x in v.iter().rev() { /* backwards */ }
collect materializes an iterator into any collection that implements FromIterator:
let names: Vec<String> = users.into_iter().map(|u| u.name).collect();
let by_id: HashMap<u32, User> = users.into_iter().map(|u| (u.id, u)).collect();
Stage 5 — Async, Concurrency, and the Toolchain
async/await
Rust’s async is cooperative, zero-cost, and runtime-optional. An async fn returns a Future; nothing runs until a runtime (executor) polls it. There is no runtime in std — by convention you use tokio (or async-std):
// Cargo.toml: tokio = { version = "1", features = ["full"] }
async fn fetch() -> String {
// .await inside async; future is polled by the runtime
String::from("data")
}
#[tokio::main]
async fn main() {
let data = fetch().await;
println!("{}", data);
}
.await yields control back to the runtime when the future can’t make progress, so one OS thread can drive thousands of tasks. Futures are state machines generated by the compiler — no allocation per .await unless you box them.
Threads and channels
For CPU-bound parallelism, std::thread plus Send/Sync guarantees make safe concurrency almost automatic. Send means a type can move between threads; Sync means &T can be shared across threads:
use std::thread;
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = c.lock().unwrap();
*n += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 10
Channels (std::sync::mpsc, or async channels in tokio) pass messages between threads/tasks — often cleaner than shared Mutex state.
Error handling: thiserror and anyhow
For libraries, define your error type with thiserror:
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("parse: {0}")]
Parse(#[from] std::num::ParseIntError),
}
For applications, anyhow gives you ergonomic error chaining:
use anyhow::{Context, Result};
fn run() -> Result<()> {
let s = std::fs::read_to_string("config.toml")
.context("reading config")?; // wrap with context
Ok(())
}
Rule of thumb: thiserror when you’re a library exposing an error type; anyhow when you’re an application that mostly needs to propagate and report.
The toolchain
Rust ships with cargo — a build system, package manager, test runner, doc generator, and formatter in one. This is a big part of why Rust feels good day-to-day.
# Install Rust (rustup manages toolchains)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# New project
cargo new my_app && cd my_app
cargo run # build + run
cargo build --release # optimized
cargo test # run tests
cargo fmt # format
cargo clippy # lints (run this always)
cargo doc --open # generate + open docs
cargo add serde # add a dependency
A minimal Cargo.toml:
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
Essential tooling:
cargo clippy— the linter. Run it in CI; it catches real bugs and idiomatic improvements.cargo fmt—rustfmt, the canonical formatter. Format on save.cargo test— built-in test runner; unit tests live in the same file under#[cfg(test)].cargo audit— scansCargo.lockfor known CVEs.rustup— manages toolchains and targets;rustup component add clippy rustfmt.- Miri — an interpreter that detects Undefined Behavior in
unsafecode; install withrustup +nightly component add miri.
A Quick-Start Checklist
- Install via
rustup, thencargo new my_app. - Read compiler errors literally. The borrow checker messages are precise and suggest fixes; follow them.
- Learn ownership and borrowing before anything else. This is the only genuinely new concept; everything else is ordinary.
- Use
Option/Result+?— neverunwrap()in code you intend to ship (it’s fine for examples and tests). - Derive
Debug,Cloneliberally; reach for traits before structs. - Use iterators and combinators instead of explicit loops when you can; they’re zero-cost and clearer.
- Run
cargo clippyandcargo fmtin CI, pluscargo audit. - Pick tokio for async,
anyhowfor app errors,thiserrorfor library errors. - Avoid
unsafeuntil you can prove you need it — and then test with Miri.
Common Pitfalls
- “cannot move out of borrowed content” — you tried to take ownership of something you only borrowed. Clone it, or change the signature to take ownership.
- “does not live long enough” — a reference outlives its owner. Restructure so the owner lives longer, or return an owned value.
- “borrowed as mutable twice” — you held a
&mutwhile taking another. Break the borrow scope: the first&mutmust end before you take the second. .unwrap()panics —Option/Resultcarried a bad value. Handle the case explicitly withmatchor?.- Fighting the borrow checker — if you’re wrestling with it, you often have a design that doesn’t match ownership. Step back: who owns this data? Make the owner explicit.
- String vs &str at boundaries — take
&str, returnString. Don’t return&strto freshly-allocated data.
What to Learn Next
- The Book — The Rust Programming Language is the canonical tutorial; free and thorough.
- Rust by Example — rust-lang.org/rust-by-example runnable snippets for every feature.
- Rustlings — github.com/rust-lang/rustlings small exercises to build muscle memory.
- The Reference — doc.rust-lang.org/reference for the precise language semantics.
- Tokio tutorial — tokio.rs/tokio/tutorial when you’re ready for async.
- Clippy — github.com/rust-lang/rust-clippy lints that teach idioms.
Rust’s learning curve is front-loaded: the borrow checker is hard in week one and then becomes the thing that lets you refactor a 200k-line codebase without fear. Once ownership is a reflex, the rest of the language rewards you with speed and a kind of compile-time confidence few other languages offer.
Good luck — and run cargo clippy.
Resources:
- The Book: https://doc.rust-lang.org/book/
- Rust by Example: https://doc.rust-lang.org/rust-by-example/
- Rustlings: https://github.com/rust-lang/rustlings
- crates.io: https://crates.io/
- Tokio: https://tokio.rs/
- Clippy: https://github.com/rust-lang/rust-clippy
Enjoyed this post? Never miss out on future posts by following us