Rust Basics — Safe and Fast Systems Programming
Status: Active | Last Updated: 2026-08-29 Category: Languages — Rust Prerequisites: Basic programming concepts; some understanding of memory concepts (see C++ memory model) helps Tags: rust, ownership, borrow, cargo, systems Estimated Time: 4-6 hours (Self-paced, includes lab time)
Summary
Rust is a systems programming language that guarantees memory safety without a garbage collector. It achieves this through an ownership and borrowing system that is checked at compile time. Learning Rust teaches you how memory works in a way no other modern language requires.
What You'll Learn (Core Competencies)
- Apply ownership rules: each value has one owner; ownership is moved, not shared by default
- Borrow values safely: immutable borrows (
&T) and mutable borrows (&mut T) with lifetime rules - Use
match,if let, and pattern destructuring to handle all possible values exhaustively - Handle errors safely with
Result<T, E>and propagate them using? - Build, test, and manage dependencies with
cargo
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Why Rust
Rust was designed for systems programming where performance and reliability matter. Key guarantees at compile time:
- No null pointer dereferences
- No data races in multi-threaded code
- No memory leaks (without
mem::forget) - No dangling pointers
These guarantees come from the borrow checker, not a garbage collector or runtime, so there is no performance overhead.
The Ownership Model
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (destructor runs). This is the core rule:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1's ownership moved to s2
// println!("{}", s1); // ERROR: s1 no longer owns the value
}
This is different from JavaScript or Python, where assignment copies a reference. In Rust, String is moved by default. Types that implement Copy (like integers) are copied, not moved.
The Borrow Checker
The borrow checker enforces two rules at compile time:
- At any time, you can have either one mutable reference OR any number of immutable references.
- References must always be valid (no dangling pointers).
These rules prevent data races at compile time, not at runtime. A data race happens when (1) two pointers access the same data, (2) at least one writes, (3) with no synchronization. Rust guarantees this cannot happen.
Lifetime Tracking
Every reference in Rust has a lifetime — a span of code during which the reference is valid. Most of the time the compiler infers lifetimes (lifetime elision). When a reference outlives the data it points to, the compiler rejects the program at build time. This eliminates dangling pointers by construction.
2. Deep Dive & Implementation
Ownership
Every value has exactly one owner. When the owner goes out of scope, the value is dropped:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1's ownership moved to s2
// println!("{}", s1); // ERROR: s1 no longer owns the value
}
This is different from JavaScript or Python, where assignment copies a reference. In Rust, String is moved by default. Types that implement Copy (like integers) are copied, not moved.
Stack vs Heap Values
Rust's ownership model treats stack-allocated and heap-allocated values differently:
fn main() {
// Stack-allocated: i32 implements Copy, so x is duplicated
let x = 5;
let y = x; // x is still valid
println!("x = {}, y = {}", x, y);
// Heap-allocated: String does NOT implement Copy, so it's moved
let s1 = String::from("hello");
let s2 = s1; // s1 is moved to s2
// println!("{}", s1); // ERROR: borrow of moved value
}
Why the difference? A String is three words on the stack (pointer, length, capacity) plus a heap buffer. Copying the stack part and re-allocating the heap would be expensive. For i32, the value is just one word — copying is trivial.
Clone When You Need a Real Copy
If you actually want two independent heap allocations, call .clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // explicit deep copy
println!("s1 = {}, s2 = {}", s1, s2); // both valid
}
clone() is explicit, so you can see where copying happens and reason about cost.
Ownership and Functions
Passing a value to a function moves it (or copies it, if Copy):
fn takes_ownership(s: String) {
println!("{}", s);
} // s is dropped here
fn makes_copy(n: i32) {
println!("{}", n);
} // n goes out of scope — nothing special (i32 is Copy)
fn main() {
let s = String::from("hello");
takes_ownership(s); // s is moved; no longer valid
// println!("{}", s); // ERROR
let n = 42;
makes_copy(n); // n is copied; still valid
println!("n = {}", n); // OK
}
Returning a value transfers ownership to the caller. This is why Rust has tuple returns like (usize, &str) — to return a value plus a borrowed reference.
The Drop Trait
When a value goes out of scope, Rust calls drop on it. This is where destructors live:
struct CustomGuard {
name: String,
}
impl Drop for CustomGuard {
fn drop(&mut self) {
println!("dropping {}", self.name);
}
}
fn main() {
let g = CustomGuard { name: String::from("first") };
let h = CustomGuard { name: String::from("second") };
// h drops first (LIFO), then g
}
Output: dropping second, then dropping first. This deterministic destruction is what makes RAII patterns work in Rust.
Borrowing and References
References let you access data without taking ownership:
fn calculate_length(s: &String) -> usize {
s.len() // read-only reference, s not moved
}
fn main() {
let s = String::from("hello");
calculate_length(&s);
println!("{}", s); // s is still valid — no move
}
A mutable reference (&mut) lets you modify the value, but only one mutable reference exists at a time, and no immutable references can coexist with it.
Mutable References
fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{}", s); // "hello, world"
}
The Borrow Checker Rules
At any point in your code, the borrow checker enforces:
- At any time, you can have either one mutable reference OR any number of immutable references.
- References must always be valid (no dangling pointers).
Violations are compile-time errors, not runtime crashes:
fn main() {
let mut s = String::from("hello");
let r1 = &s; // OK
let r2 = &s; // OK — multiple immutable borrows
// let r3 = &mut s; // ERROR — cannot borrow as mutable while immutable borrows exist
println!("{} {}", r1, r2);
// After r1, r2 go out of scope, the borrow ends
let r3 = &mut s; // OK now
r3.push_str("!");
}
This rule prevents data races at compile time. A data race happens when (1) two pointers access the same data, (2) at least one writes, (3) with no synchronization. Rust guarantees this cannot happen.
Non-Lexical Lifetimes (NLL)
The borrow checker is smarter than "from declaration to end of scope." It uses non-lexical lifetimes: a reference is live only as long as it is actually used:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2); // last use of r1 and r2
let r3 = &mut s; // OK — r1, r2 are no longer used
r3.push_str("!");
println!("{}", r3);
}
Even though r1 and r2 are still "in scope," their last use is the println!, so the borrow ends there.
Mutable Reference Scope Gotcha
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ERROR: second mutable borrow
// (unless r1's last use is before r2)
println!("{} {}", r1, r2);
}
Fix: scope the borrows explicitly, or rely on NLL by ordering uses.
Lifetimes
Every reference in Rust has a lifetime: a span of code during which the reference is valid. Most of the time, the compiler infers lifetimes (this is called lifetime elision). Sometimes you need to write them out.
Why Lifetimes Exist
References must never outlive the data they point to. Lifetimes are the compiler's way of tracking that:
fn main() {
let r; // ---------+-- 'a
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// println!("{}", r); // | // ERROR: x does not live long enough
} // ---------+
'a and 'b are lifetime labels. r has lifetime 'a, but the data x has lifetime 'b, which is shorter. The compiler rejects the program.
Lifetime Annotations in Function Signatures
When a function takes references and returns a reference, the compiler needs to know how the lifetimes relate:
// Without annotations — compile error
// fn longest(s1: &str, s2: &str) -> &str {
// With annotations — OK
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() { s1 } else { s2 }
}
fn main() {
let a = String::from("long string");
let b = "short";
println!("{}", longest(&a, b));
}
<'a> declares a generic lifetime parameter. The signature says: the returned reference lives at least as long as the shorter of the two input references.
The 'static Lifetime
'static means the reference can live for the entire program. String literals are 'static:
let s: &'static str = "I live forever";
Use 'static for global config, error messages, and constants. Avoid it in function signatures unless the data is genuinely static — 'static is a strong requirement, not a default.
Structs with References
When a struct holds a reference, you must annotate the lifetime:
struct Excerpt<'a> {
text: &'a str,
}
impl<'a> Excerpt<'a> {
fn first_word(&self) -> &'a str {
// Returns a reference tied to self's lifetime
self.text.split_whitespace().next().unwrap_or("")
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first = Excerpt { text: &novel };
println!("{}", first.first_word()); // "Call"
}
The struct cannot outlive the data it borrows. Drop the novel and the Excerpt becomes invalid.
Lifetime Elision Rules
In many cases, the compiler infers lifetimes so you don't have to write them:
- Each input reference gets its own lifetime parameter.
- If there's exactly one input lifetime, that lifetime is assigned to all output references.
- If there are multiple input lifetimes and one is
&selfor&mut self, the lifetime ofselfis assigned to all output references.
That's why fn first_word(&self, s: &str) -> &str works without annotations — rule 3 applies.
Pattern Matching
Pattern matching is how Rust deconstructs data. It's used in match, if let, while let, function parameters, and let bindings.
Match Expressions
match is exhaustive — the compiler verifies you covered every case:
enum Coin {
Penny,
Nickel,
Dime,
Quarter(String), // US state
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("quarter from {}", state);
25
}
}
}
fn main() {
let c = Coin::Quarter(String::from("Alaska"));
println!("{} cents", value_in_cents(c));
}
The compiler refuses to compile if you add a new variant to Coin and forget to update the match. This is "make invalid states unrepresentable" applied to control flow.
Binding Values from Patterns
fn describe(x: Option<i32>) -> &'static str {
match x {
Some(0) => "zero",
Some(n) if n > 0 => "positive",
Some(_) => "non-positive",
None => "nothing",
}
}
Some(n) binds n; Some(_) ignores the value. The if n > 0 is a match guard — an extra condition.
if let and while let
When you only care about one pattern, if let is shorter than match:
fn main() {
let some_value = Some(7);
// Verbose
match some_value {
Some(v) => println!("got {}", v),
_ => (),
}
// Concise
if let Some(v) = some_value {
println!("got {}", v);
}
// while let for streams
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("{}", top);
}
}
Destructuring
Patterns can destructure tuples, structs, and enums:
struct Point { x: i32, y: i32 }
fn main() {
// Tuples
let (a, b) = (1, 2);
// Structs
let p = Point { x: 10, y: 20 };
let Point { x, y } = p;
println!("{} {}", x, y);
// Nested
let ((a, b), Point { x, y }) = ((1, 2), Point { x: 3, y: 4 });
println!("{} {} {} {}", a, b, x, y);
}
Matching on Slices and Ranges
fn classify(n: i32) -> &'static str {
match n {
0 => "zero",
1..=9 => "single digit",
10..=99 => "two digits",
_ => "big",
}
}
..= is an inclusive range. _ is a catch-all that matches anything.
Error Handling with Result
Rust has no exceptions. Errors are values of type Result<T, E>:
enum Result<T, E> {
Ok(T),
Err(E),
}
Functions that can fail return Result. The caller decides how to handle errors.
The ? Operator
? propagates errors up the call stack:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("username.txt")?.read_to_string(&mut s)?;
Ok(s)
}
? is shorthand for: "if this is Err, return it; if it's Ok, unwrap the value." It also performs an implicit From conversion so error types can differ.
Unwrap and Expect
For prototypes and known-impossible errors, .unwrap() panics on Err:
let f = File::open("config.toml").unwrap();
let f = File::open("config.toml").expect("config.toml must exist");
.expect(msg) panics with a custom message. Use these only when:
- The error is genuinely impossible (e.g., a hardcoded path)
- You are in early development and a panic is acceptable
- You are writing a test
In production code, prefer ? or explicit match.
Custom Error Types
For libraries, define a custom error enum and implement std::error::Error:
use std::fmt;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(std::num::ParseIntError),
NotFound,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {}", e),
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::NotFound => write!(f, "not found"),
}
}
}
impl std::error::Error for AppError {}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_port(path: &str) -> Result<u16, AppError> {
let s = std::fs::read_to_string(path)?; // io::Error -> AppError
let n: u16 = s.trim().parse()?; // ParseIntError -> AppError
Ok(n)
}
The From implementations let ? work seamlessly. This pattern is the foundation of libraries like anyhow and thiserror.
Option vs Result
Option<T>: absence is normal (HashMap::get,Vec::first)Result<T, E>: operation can fail (File::open,str::parse)
Use .ok() and .ok_or() to convert between them when the conversion makes sense:
let port: u16 = std::env::var("PORT")
.ok() // Option<String>
.and_then(|s| s.parse().ok()) // Option<u16>
.unwrap_or(8080);
When to Panic
Panic when:
- The invariant is broken (a bug you cannot recover from)
- You are in a test (panic shows up as a test failure)
- The alternative is unsafe code
Don't panic when:
- The error is expected (parse failure, missing file)
- A library caller might want to handle it
- The system can recover
Cargo: Building and Running
Cargo is Rust's build system, package manager, test runner, and documentation generator.
Creating a Project
cargo new my_app # binary crate
cargo new --lib my_lib # library crate
Generated structure:
my_app/
├── Cargo.toml
├── src/
│ └── main.rs
├── tests/ # integration tests
└── target/ # build output (gitignored)
Cargo.toml
[package]
name = "my_app"
version = "0.1.0"
edition = "2021" # language edition; 2021/2024 are current
authors = ["you@example.com"]
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
clap = "4"
[dev-dependencies]
proptest = "1"
[profile.release]
opt-level = 3
lto = true # link-time optimization
codegen-units = 1
strip = true
[dependencies] are normal deps; [dev-dependencies] are only for tests/examples/benches; [build-dependencies] are for build.rs.
Essential Commands
cargo build # debug build
cargo build --release # optimized build
cargo run # build + run
cargo check # fast type check (no codegen)
cargo test # run all tests
cargo test --doc # doc tests only
cargo test integration_test # single integration test file
cargo bench # run benchmarks (nightly)
cargo clippy # lints
cargo fmt # format
cargo doc --open # build and open docs
cargo update # update Cargo.lock
cargo tree # dependency tree
Dependencies from crates.io
Add to Cargo.toml:
[dependencies]
serde = "1.0"
serde_json = "1.0"
Then use them in code:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
age: u32,
}
fn main() {
let u = User { name: String::from("Ada"), age: 36 };
let json = serde_json::to_string(&u).unwrap();
println!("{}", json);
}
Cargo.lock records exact versions for reproducible builds. Commit it for binaries; for libraries, leave it out so downstream users resolve their own versions.
Workspaces
For multi-crate projects:
# Cargo.toml at workspace root
[workspace]
members = ["crates/core", "crates/cli", "crates/server"]
[workspace.dependencies]
serde = "1"
tokio = "1"
Member crates inherit workspace deps via serde.workspace = true. This keeps versions in one place.
Cargo Features
Conditional compilation:
[features]
default = ["json"]
json = ["dep:serde_json"]
yaml = ["dep:serde_yaml"]
#[cfg(feature = "json")]
fn load_json() { ... }
Features are additive — disabling json removes the json module from compilation. This is how optional dependencies work.
Build Profiles
Cargo has four profiles: dev, release, test, bench.
| Profile | opt-level | debug | Incremental |
|---|---|---|---|
| dev | 0 | yes | yes |
| release | 3 | no | no |
Use cargo build --release for benchmarks and production. The default dev build prioritizes compile speed over runtime speed.
Guided Checkpoint
Create a new binary crate, build it in release mode, and verify the binary runs independently:
mkdir -p /tmp/rust-checkpoint && cd /tmp/rust-checkpoint
cargo init --name checkpoint
cat > src/main.rs <<'EOF'
fn main() {
println!("Rust binary running — release build verified.");
}
EOF
cargo build --release
ls -la target/release/checkpoint
file target/release/checkpoint # should show "executable" and architecture
./target/release/checkpoint # outputs the message
3. Anti-Patterns & Common Pitfalls
These failure modes appear repeatedly in Rust codebases. Each is detectable, preventable, and rooted in a misunderstanding of the borrow checker or ownership rules.
Anti-Pattern: Cloning Instead of Borrowing
Using .clone() when a reference would work avoids the borrow checker but creates unnecessary allocations and copies:
// Bad: unnecessary clone of every string
fn process_users(users: Vec<String>) {
for user in users.clone() { // clones the entire vector
print!("{}", user);
}
}
// Good: borrow the vector
fn process_users(users: &[String]) {
for user in users {
print!("{}", user);
}
}
Fix: prefer borrowing (&T) over cloning when the caller does not need ownership of the result. Clone only when the data must outlive the borrow scope.
Anti-Pattern: Fighting the Borrow Checker
Trying to hold both mutable and immutable references to the same data at the same time produces compile errors. The fix is usually restructuring, not disabling checks:
// Bad: wants to modify and read the same data simultaneously
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // ERROR: mutable borrow conflicts with r1
Fix: reorder the operations so the immutable borrow ends before the mutable borrow begins. Use the let scopes to make this explicit:
// Good: separate scopes by ordering
let mut s = String::from("hello");
{
let r1 = &s;
println!("{}", r1);
} // r1's borrow ends here
let r2 = &mut s;
r2.push_str(", world!");
Anti-Pattern: Panic on Routine Errors
Using .unwrap() or .expect() for errors that are expected (missing files, parse errors, network timeouts) makes the program brittle:
// Bad: crashes on any missing file
let config = std::fs::read_to_string("config.toml").unwrap();
// Good: returns an error to the caller
fn load_config(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
Fix: use ? to propagate errors, match to handle specific error cases, and reserve unwrap() for genuinely impossible conditions (like a hardcoded path in a test).
Anti-Pattern: Over-Restricting Lifetimes
Over-annotating lifetimes with 'static or overly long lifetimes makes APIs harder to use:
// Over-restricted: requires the data to live forever
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str { ... }
// More flexible: allows shorter lifetimes as needed
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str { ... }
Fix: rely on lifetime elision where possible. Write lifetime annotations only when the compiler requires them (usually when a function takes multiple reference inputs and returns one of them).
4. Independent Challenge
Implement a safe, concurrent bounded buffer (ring buffer) for byte streams.
Without step-by-step guidance, produce:
- A
RingBufferstruct with a fixed capacity (provided at construction, must be a power of 2). - A concurrent
push(data: &[u8]) -> usizemethod that writes up to the available capacity and returns how many bytes were written. - A concurrent
pop() -> Vec<u8>method that reads all available bytes and removes them from the buffer. - A
len()method that reports the number of bytes currently buffered. - Use
std::sync::Arc,std::sync::Mutex, or a lock-free approach withstd::sync::atomic. - No data races:
go test -racemust pass on a multi-threaded test.
Constraints:
- The buffer must work correctly when multiple threads push and pop concurrently.
- The capacity is fixed; additional pushes that exceed capacity must return the number of bytes actually written (partial writes are allowed) or block — document which behavior you chose.
- The design must not leak memory or goroutines.
- The API should allow reading from the buffer without consuming it (a
peek()method) so callers can decide whether to pop.
Deliverable: a ring.rs file with tests. Verify with cargo test -- --nocapture and cargo test -- --race. No solution is provided.
5. Consolidation & Key Invariants
- Every value has one owner. Ownership moves when assigned (unless the type implements
Copy). After a move, the original variable is no longer valid. - References never outlive their owner. The borrow checker ensures this at compile time through lifetime tracking. There are no dangling pointers.
- One mutable reference OR any number of immutable references. Not both at the same time. This prevents data races at compile time.
- Errors are values. Never ignore errors silently. Prefer
?or explicitmatchover.unwrap()in production code. - Use
context.Contextfor cancellation. Pass context as the first argument to any function that performs I/O or spawns goroutines. - Clone explicitly when needed. Prefer borrowing (
&T) over cloning to avoid unnecessary allocations. Clone only when the data must outlive the borrow. - Never disable the borrow checker. There is no
unsafeescape hatch for routine code.unsafeis reserved for FFI, low-level memory management, or performance-critical sections with verified invariants.
6. Next Steps
- Rust Book — official tutorial
- Rust by Example — learn by annotated snippets
- std docs — standard library reference
- Tokio tutorial — async I/O
- Practice: write a CLI tool, then a small web service with
axumoractix-web
Next Article: nodejs-runtime — see how V8 differs from a compiled language runtime, or cpp-basics for a comparison with manual memory management.
Content depth matches kb/containers/docker-concepts.md — production-level patterns, real syntax, and practical examples.
Change Log
| Date | Change |
|---|---|
| 2026-09-15 | Migrated to canonical 6-section template (Architectural Overview, Deep Dive with Guided Checkpoint, Anti-Patterns, Independent Challenge, Consolidation, Next Steps) |
| 2026-08-29 | Full hydration of Rust Basics lesson |
| 2026-08-29 | Added sections: Why Rust, Ownership, Borrowing, Lifetimes, Pattern Matching, Error Handling, Cargo |
| 2026-09-15 | Added Next Steps links, preserved prerequisites and tags |