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.

Rust Roadmap

  1. Fundamentals — variables, mut, types, control flow, String vs &str
  2. Ownership + Borrow — the borrow checker, references, lifetimes, slices
  3. Structs + Enums — structs, impl, enums, pattern matching, Option and Result
  4. Traits + Generics — traits, static vs dynamic dispatch, iterators
  5. Async + Ecosystemasync/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.

Rust Ownership

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) or None — “a value that may be absent”
  • Result<T, E> = Ok(T) or Err(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

Rust Traits

  • 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 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 fmtrustfmt, the canonical formatter. Format on save.
  • cargo test — built-in test runner; unit tests live in the same file under #[cfg(test)].
  • cargo audit — scans Cargo.lock for known CVEs.
  • rustup — manages toolchains and targets; rustup component add clippy rustfmt.
  • Miri — an interpreter that detects Undefined Behavior in unsafe code; install with rustup +nightly component add miri.

A Quick-Start Checklist

  1. Install via rustup, then cargo new my_app.
  2. Read compiler errors literally. The borrow checker messages are precise and suggest fixes; follow them.
  3. Learn ownership and borrowing before anything else. This is the only genuinely new concept; everything else is ordinary.
  4. Use Option/Result + ? — never unwrap() in code you intend to ship (it’s fine for examples and tests).
  5. Derive Debug, Clone liberally; reach for traits before structs.
  6. Use iterators and combinators instead of explicit loops when you can; they’re zero-cost and clearer.
  7. Run cargo clippy and cargo fmt in CI, plus cargo audit.
  8. Pick tokio for async, anyhow for app errors, thiserror for library errors.
  9. Avoid unsafe until 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 &mut while taking another. Break the borrow scope: the first &mut must end before you take the second.
  • .unwrap() panicsOption/Result carried a bad value. Handle the case explicitly with match or ?.
  • 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, return String. Don’t return &str to freshly-allocated data.

What to Learn Next

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:

Watch PyShine on YouTube

Contents