Rust Fundamentals
Rust is a compiled, statically typed systems language whose defining feature is a compile-time ownership model: it guarantees memory safety and data-race freedom without a garbage collector. This guide assumes you already program in another language and focuses on what makes Rust different. For installing the toolchain and creating a project, see the Rust scaffolding guide. New projects default to edition 2024.
The canonical reference is The Rust Programming Language — "the book" — which is free online and far more thorough than this page. Each section below links to the chapter that covers it in depth; see Resources at the end for more, including an interactive edition with quizzes.
A first program
cargo new hello generates a src/main.rs with an entry point. main takes no arguments and
returns nothing; println! is a macro (the ! marks it), and cargo run compiles and runs.
fn main() {
println!("Hello, world!");
}Variables and mutability
Bindings are created with let and are immutable by default — once bound, the value cannot
change. This is the opposite of most languages and is a deliberate nudge toward predictable code.
Opt into mutation with mut
(book ch. 3.1).
let x = 5;
// x = 6; // error: cannot assign twice to immutable variable
let mut y = 5;
y = 6; // okShadowing re-binds a name with a new let, which can change the type. It is distinct from
mutation: you are creating a new variable that happens to reuse the name.
let spaces = " "; // &str
let spaces = spaces.len(); // usize — a new binding shadows the oldA const is a compile-time constant. It requires a type annotation, is always immutable (no
mut), and can be declared in any scope including the module top level.
const MAX_POINTS: u32 = 100_000;Scalar and compound types
Rust infers most types, but every value has one. The scalar types are integers (i8…i128,
u8…u128, and the pointer-sized isize/usize), floats (f32, f64), bool, and char (a
4-byte Unicode scalar, written with single quotes). Integer literals default to i32, floats to
f64 (book ch. 3.2).
let count: i64 = 42;
let ratio = 2.5; // f64
let ready: bool = true;
let grade = 'A'; // char, not StringThe two primitive compound types are tuples (fixed-length, mixed types) and arrays (fixed-length,
single type). A growable list is a Vec, covered below.
let point: (i32, i32) = (3, 7);
let (px, py) = point; // destructuring
let first = point.0; // index access
let week: [&str; 2] = ["Mon", "Tue"];
let zeros = [0; 5]; // [0, 0, 0, 0, 0]Functions
Functions are declared with fn. Parameter types are always required; the return type follows
->. The body is a block, and a block's final expression without a semicolon is its value — so
the idiomatic return is simply to omit the semicolon on the last line
(book ch. 3.3).
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon: this is the return value
}if and blocks are expressions too, so they can produce values directly:
fn classify(n: i32) -> &'static str {
if n < 0 { "negative" } else { "non-negative" }
}Control flow
if conditions must be bool — there is no truthiness, so if x only works when x is already a
boolean. Rust has three loops: loop (infinite, often paired with break value to return a
result), while, and for over an iterator or range
(book ch. 3.5).
let mut total = 0;
for i in 1..=5 { // 1..=5 is inclusive; 1..5 is exclusive
total += i;
}
let mut n = 0;
let doubled = loop {
n += 1;
if n == 10 { break n * 2; } // break can carry a value out of loop
};Ownership
Ownership is the rule set the borrow checker enforces, and it is what lets Rust free memory without a garbage collector. Three rules: every value has exactly one owner; there is only one owner at a time; when the owner goes out of scope, the value is dropped (freed). Ownership gets a full chapter in the book — ch. 4.1 — and it is the one concept most worth reading in full.
For types stored on the heap, such as String, assigning or passing a value moves it. The
original binding becomes invalid, which prevents two owners from freeing the same memory.
let s1 = String::from("hello");
let s2 = s1; // s1 is MOVED into s2
// println!("{s1}"); // error: borrow of moved value: `s1`
println!("{s2}"); // okTo keep using the original, .clone() makes a deep copy. Small stack-only types (integers, bool,
char, and tuples of them) implement the Copy trait and are duplicated instead of moved, so the
original stays valid.
let a = String::from("hi");
let b = a.clone(); // explicit deep copy; both a and b are valid
let x = 5;
let y = x; // i32 is Copy; x is still usable
println!("{x} {y}");Passing a value to a function moves it too — unless you pass a reference, described next.
References and borrowing
Borrowing lets a function read or modify a value without taking ownership, so the caller keeps
it. A reference is written &value; a mutable reference is &mut value. The borrow checker
enforces one rule that eliminates data races at compile time: at any given time you may have either
any number of shared (&) references or exactly one mutable (&mut) reference — never both
(book ch. 4.2).
fn len(s: &String) -> usize { // borrows, does not take ownership
s.len()
}
fn push_bang(s: &mut String) { // mutable borrow
s.push('!');
}
let mut greeting = String::from("hi");
let n = len(&greeting); // shared borrow
push_bang(&mut greeting); // mutable borrow
println!("{greeting} ({n})"); // greeting still owned here -> "hi! (2)"A &str (string slice) is a borrowed view into string data; a String is an owned, growable
buffer. Prefer &str for function parameters that only read text — it accepts both String and
string literals.
Structs
A struct groups related fields under a name. Methods live in a separate impl block. A method's
first parameter is &self (read), &mut self (mutate), or self (take ownership); an associated
function omits self and is called with ::, commonly as a constructor named new
(book ch. 5).
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn new(width: u32, height: u32) -> Self {
Self { width, height } // field init shorthand
}
fn area(&self) -> u32 {
self.width * self.height
}
}
let rect = Rectangle::new(3, 4);
println!("{}", rect.area()); // 12Enums and pattern matching
An enum is a type that is exactly one of several variants, and each variant can carry its own data. This makes enums far more expressive than in most languages — they model "one of these shapes" precisely (book ch. 6).
enum Shape {
Circle(f64), // radius
Rectangle(f64, f64), // width, height
}match inspects a value against patterns and must be exhaustive — the compiler rejects it if
any variant is unhandled, so adding a variant later forces you to update every match. Each arm can
destructure the variant's data.
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
}
}When you only care about one pattern, if let is a concise alternative to a full match:
let maybe = Some(7);
if let Some(n) = maybe {
println!("got {n}");
}Collections
The standard collections live on the heap and grow at runtime. The three you reach for constantly
are Vec<T> (a growable array), String (growable UTF-8 text), and HashMap<K, V> (key-value
lookup) (book ch. 8).
use std::collections::HashMap;
let mut scores: Vec<i32> = Vec::new();
scores.push(10);
scores.push(20);
let total: i32 = scores.iter().sum(); // 30
let mut counts: HashMap<&str, i32> = HashMap::new();
*counts.entry("apple").or_insert(0) += 1; // insert-or-update idiom
for (fruit, n) in &counts {
println!("{fruit}: {n}");
}Indexing a Vec with [i] panics if i is out of bounds; .get(i) returns an Option instead,
which leads to error handling.
Error handling
Rust has no null and no exceptions. Absence and failure are ordinary values you handle explicitly,
which means the type signature tells you what can go wrong
(book ch. 9).
Option<T> represents a value that may be absent: Some(value) or None. Result<T, E>
represents an operation that may fail: Ok(value) or Err(error). You unwrap both by matching, or
with helpers like unwrap_or.
fn first_word(s: &str) -> Option<&str> {
s.split_whitespace().next()
}
match first_word("hello world") {
Some(w) => println!("first: {w}"),
None => println!("empty"),
}The ? operator is the workhorse: applied to a Result (or Option), it returns the value on
success, or short-circuits and returns the Err/None from the enclosing function. It replaces
pages of manual matching.
use std::num::ParseIntError;
fn sum_strings(a: &str, b: &str) -> Result<i32, ParseIntError> {
let x: i32 = a.parse()?; // returns early if parse fails
let y: i32 = b.parse()?;
Ok(x + y)
}Reserve panic! (and .unwrap() / .expect(), which panic on None/Err) for unrecoverable bugs
or quick prototypes — for anything a caller might reasonably handle, return a Result.
.unwrap() is fine in examples and tests but a code smell in production paths: it converts a
recoverable error into a crash. Prefer ? to propagate, or match/unwrap_or to handle.
Putting it together
This program ties the pieces together: a struct with a method, a Vec, iteration, and a fallible
function that uses ? and returns a Result. Parsing the prices can fail, so main itself returns
a Result and uses ?.
struct Order {
item: String,
price: f64,
}
impl Order {
fn new(item: &str, price: f64) -> Self {
Self { item: item.to_string(), price }
}
}
fn parse_order(line: &str) -> Result<Order, std::num::ParseFloatError> {
let (item, price) = line.split_once(',').unwrap_or((line, "0"));
Ok(Order::new(item.trim(), price.trim().parse()?))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let lines = ["coffee, 3.50", "bagel, 2.25", "tea, 2.00"];
let mut orders: Vec<Order> = Vec::new();
for line in lines {
orders.push(parse_order(line)?); // ? propagates a parse error
}
let total: f64 = orders.iter().map(|o| o.price).sum();
for o in &orders {
println!("{:<8} ${:.2}", o.item, o.price);
}
println!("total ${total:.2}");
Ok(())
}Box<dyn std::error::Error> is a catch-all error type that any concrete error can convert into via
?, which is why main can return it directly. From here, the next steps are traits (shared
behavior across types), generics, and iterators — the tools that make the ownership model
productive at scale. The book covers these in
ch. 10 and
ch. 13; working through it
front to back is the single best way to learn Rust. To apply these fundamentals to a real
program, see Rust CLIs with clap.
Resources
The Rust Programming Language ("the book") is the canonical, free, and continuously updated introduction — the sections above link to its individual chapters.
For learning, the Brown CS interactive edition is the same book with inline quizzes, highlight-to-annotate, and a substantially rewritten Understanding Ownership chapter whose visualizations were shown by research to improve comprehension. It's an excellent way to check your understanding as you read.
A few more references worth bookmarking: Rust by Example for runnable, annotated snippets; the standard library docs for every type and method; Rustlings for small hands-on exercises; and the official playground for running code in the browser without a local install.