Rust Programming Language: Getting Started
Rust has been voted the most loved programming language in Stack Overflow's Developer Survey for nine consecutive years — and for good reason. It gives you the raw performance of C and C++ while eliminating entire categories of bugs at compile time, with no garbage collector to slow you down. Whether you're building web services, command-line tools, embedded systems, or WebAssembly modules, Rust delivers speed, safety, and reliability in a single package.
This guide takes you from zero to productive in Rust. You'll install the toolchain, understand the ownership model that makes Rust unique, write real code with structs and enums, and build a small practical program — all while learning the reasoning behind Rust's design decisions.
Most languages choose between safety and performance. Go uses a garbage collector for safety but adds latency. C gives you full control but lets you shoot yourself in the foot. Rust's ownership system enforces memory safety rules at compile time, so there's no runtime overhead and no undefined behavior — the compiler catches the errors before your code ever runs.
Rust vs the Competition
Before diving into code, it helps to understand where Rust sits relative to other popular languages. The trade-offs are real, and choosing Rust deliberately makes learning it much easier.
| Feature | Rust | Go | C++ | Python |
|---|---|---|---|---|
| Memory management | Ownership (compile-time) | Garbage collector | Manual / RAII | Garbage collector |
| Runtime speed | Native (C-level) | Fast (GC pauses) | Native (C-level) | Slow (interpreted) |
| Memory safety | Guaranteed | Guaranteed | Programmer's responsibility | Guaranteed |
| Concurrency safety | Data-race free (compile-time) | Channels + go routines | Programmer's responsibility | GIL-limited |
| Learning curve | Steep | Gentle | Very steep | Easy |
| Use cases | Systems, web, WASM, CLI | Services, CLI, DevOps | Systems, games, OS | Scripting, data, ML |
Rust's sweet spot is workloads where you need predictable latency and zero overhead — game engines, network services, OS kernels, compilers, and browser engines. Mozilla built Firefox's rendering engine in Rust; Amazon runs large parts of AWS infrastructure on it; and the Linux kernel has accepted Rust as its second official language.
Installation and Your First Program
Rust is installed through rustup, the official toolchain manager. It handles the compiler (rustc), the package manager and build tool (cargo), and standard library updates in one command.
Install rustup
On macOS and Linux, run the official installer script. On Windows, download and run rustup-init.exe from rustup.rs.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Restart your shell, then verify
rustc --version # rustc 1.78.0 (or newer)
cargo --version # cargo 1.78.0
Create a new project with Cargo
Cargo is Rust's all-in-one build tool and package manager. It creates projects, manages dependencies, builds, tests, and runs your code.
# Create a new binary project
cargo new hello-rust
cd hello-rust
# Project structure:
# hello-rust/
# ├── Cargo.toml ← project metadata and dependencies
# └── src/
# └── main.rs ← entry point
# Build and run
cargo run
Your first Rust program
Open src/main.rs. The generated file already contains a Hello World. Let's extend it to explore basic syntax.
fn main() {
// Variables are immutable by default
let name = "Rust";
let version: u32 = 1;
// Use `mut` to make a variable mutable
let mut count = 0;
count += 1;
println!("Hello from {} {}!", name, version);
println!("Count: {}", count);
// Shadowing: re-bind a name to a new value
let spaces = " ";
let spaces = spaces.len(); // now an integer
println!("Spaces: {}", spaces);
// Basic arithmetic
let sum = 5 + 10;
let product = 4 * 30;
let remainder = 43 % 5;
println!("Sum={} Product={} Remainder={}", sum, product, remainder);
}
Add a dependency
Edit Cargo.toml to add packages from crates.io, Rust's package registry. Run cargo build to download and compile them.
[package]
name = "hello-rust"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"
Ownership: The Heart of Rust
Ownership is Rust's most distinctive feature — the mechanism that makes memory safety possible without a garbage collector. Every other language either tracks heap memory at runtime (GC) or makes the programmer track it manually. Rust does it at compile time with three simple rules:
- Each value in Rust has a single owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped (freed).
When s1 is moved into s2, s1 is no longer valid — the compiler enforces this. Borrowing lets you use values without taking ownership.
fn main() {
// --- MOVE semantics ---
let s1 = String::from("hello");
let s2 = s1; // s1 is *moved* into s2
// println!("{}", s1); // ERROR: s1 is no longer valid!
println!("{}", s2); // OK
// --- CLONE: explicit deep copy ---
let s3 = s2.clone(); // expensive, but both s2 and s3 are valid
println!("s2={} s3={}", s2, s3);
// --- Copy types (stack-only values like integers) ---
let x = 5;
let y = x; // integers implement Copy, so x is still valid
println!("x={} y={}", x, y);
// --- BORROWING: pass a reference instead of moving ---
let s4 = String::from("world");
let len = calculate_length(&s4); // pass a reference
println!("{} has {} chars", s4, len); // s4 still valid
// --- MUTABLE borrowing ---
let mut s5 = String::from("hello");
change(&mut s5);
println!("{}", s5); // "hello world"
}
fn calculate_length(s: &String) -> usize {
s.len() // s is a reference; no ownership is taken
}
fn change(s: &mut String) {
s.push_str(" world");
}
The Borrowing Rules
Rust enforces two borrowing rules at compile time that eliminate data races entirely:
- You can have any number of immutable references (
&T) at the same time. - You can have exactly one mutable reference (
&mut T) at a time — and no immutable references simultaneously.
Fighting the borrow checker is the number-one frustration for Rust beginners. When the compiler rejects your code, it almost always means there's a genuine bug — a potential data race, a use-after-free, or a dangling reference. Trust the compiler. When it tells you something is wrong, the error message usually explains exactly what to do instead.
Structs, Enums, and Pattern Matching
Rust uses structs to group related data and enums to represent values that can be one of several variants. Together with pattern matching, these three features give you expressive, type-safe data modeling without classes or inheritance.
Structs and Methods
// Define a struct
#[derive(Debug)]
struct Rectangle {
width: f64,
height: f64,
}
// Implement methods on the struct
impl Rectangle {
// Associated function (like a static method / constructor)
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
// Method: takes &self (immutable reference to self)
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
fn is_square(&self) -> bool {
self.width == self.height
}
// Method that takes another Rectangle
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
fn main() {
let rect1 = Rectangle::new(30.0, 50.0);
let rect2 = Rectangle { width: 10.0, height: 40.0 };
println!("rect1 = {:?}", rect1);
println!("Area: {}", rect1.area());
println!("Perimeter: {}", rect1.perimeter());
println!("Is square: {}", rect1.is_square());
println!("rect1 can hold rect2: {}", rect1.can_hold(&rect2));
}
Enums with Data
Rust enums are far more powerful than those in most languages — each variant can carry data of different types. This is the foundation of Rust's error-handling and option types.
#[derive(Debug)]
enum Shape {
Circle(f64), // one f64 (radius)
Rectangle(f64, f64), // width, height
Triangle { base: f64, height: f64 } // named fields
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
fn name(&self) -> &str {
match self {
Shape::Circle(_) => "Circle",
Shape::Rectangle(..) => "Rectangle",
Shape::Triangle { .. } => "Triangle",
}
}
}
fn describe_number(n: i32) -> &'static str {
match n {
0 => "zero",
1..=9 => "single digit",
10..=99 => "double digit",
100..=999 => "triple digit",
_ => "large number", // _ is the catch-all
}
}
fn main() {
let shapes = vec![
Shape::Circle(5.0),
Shape::Rectangle(4.0, 6.0),
Shape::Triangle { base: 3.0, height: 8.0 },
];
for shape in &shapes {
println!("{}: area = {:.2}", shape.name(), shape.area());
}
println!("{}", describe_number(42)); // "double digit"
}
if let — Concise Pattern Matching
let config_max = Some(3u8);
// Verbose match:
match config_max {
Some(max) => println!("Max is {}", max),
_ => (),
}
// Concise if let:
if let Some(max) = config_max {
println!("Max is {}", max);
}
// with else:
if let Some(max) = config_max {
println!("Max is {}", max);
} else {
println!("No max configured");
}
Error Handling with Result and Option
Rust has no exceptions. Instead, functions that can fail return a Result<T, E>, and functions that might return nothing return an Option<T>. This forces callers to handle failure explicitly — a design that eliminates an entire class of runtime crashes.
Option<T> replaces null. It is either Some(value) or None:
fn find_first_even(numbers: &[i32]) -> Option<i32> {
for &n in numbers {
if n % 2 == 0 {
return Some(n);
}
}
None
}
fn main() {
let nums = vec![1, 3, 5, 8, 11];
match find_first_even(&nums) {
Some(n) => println!("First even: {}", n),
None => println!("No even numbers"),
}
// Convenience methods on Option
let result = find_first_even(&nums);
println!("{}", result.unwrap_or(0)); // 8
println!("{}", result.is_some()); // true
println!("{}", result.map(|n| n * 2).unwrap()); // 16
// unwrap() panics if None — only use when you're certain
let x: Option<i32> = Some(42);
println!("{}", x.unwrap());
}
Result<T, E> represents either success (Ok(T)) or failure (Err(E)):
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
let n = s.trim().parse::<i32>()?; // ? propagates the error
Ok(n * 2)
}
fn main() {
match parse_and_double("21") {
Ok(n) => println!("Result: {}", n), // Result: 42
Err(e) => println!("Error: {}", e),
}
match parse_and_double("abc") {
Ok(n) => println!("Result: {}", n),
Err(e) => println!("Error: {}", e), // invalid digit found in string
}
// Convenience methods
let ok: Result<i32, &str> = Ok(10);
println!("{}", ok.unwrap_or(0)); // 10
println!("{}", ok.is_ok()); // true
println!("{}", ok.map(|n| n + 1).unwrap()); // 11
}
The ? operator is syntactic sugar for propagating errors up the call stack:
use std::fs;
use std::io;
// Without ? — verbose
fn read_username_verbose(path: &str) -> Result<String, io::Error> {
let result = fs::read_to_string(path);
match result {
Ok(s) => Ok(s.trim().to_string()),
Err(e) => Err(e),
}
}
// With ? — clean and idiomatic
fn read_username(path: &str) -> Result<String, io::Error> {
let username = fs::read_to_string(path)?.trim().to_string();
Ok(username)
}
// Chain multiple fallible operations
fn read_and_process(path: &str) -> Result<Vec<String>, io::Error> {
let content = fs::read_to_string(path)?;
let lines: Vec<String> = content
.lines()
.map(|l| l.to_uppercase())
.collect();
Ok(lines)
}
fn main() {
match read_username("user.txt") {
Ok(name) => println!("Hello, {}!", name),
Err(e) => eprintln!("Could not read username: {}", e),
}
}
Use panic! for unrecoverable programmer errors; use Result for expected failure scenarios:
// panic! — for bugs that should never happen in correct code
fn get_first(v: &[i32]) -> i32 {
if v.is_empty() {
panic!("get_first called with empty slice — this is a bug");
}
v[0]
}
// Result — for expected failures (file not found, bad input, etc.)
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
return Err(String::from("Division by zero"));
}
Ok(a / b)
}
// Guidelines:
// - panic! when an invariant is broken (bad index, logic error)
// - Result when failure is expected and callers should handle it
// - unwrap() only in prototypes or tests where panic is acceptable
fn main() {
// Good: handle the Result
match divide(10.0, 3.0) {
Ok(result) => println!("{:.2}", result),
Err(e) => println!("Error: {}", e),
}
// Lazy: only acceptable in short scripts / tests
let result = divide(10.0, 2.0).unwrap();
println!("{}", result);
}
Concurrency: Fearless by Design
Rust's ownership system doesn't just prevent memory bugs — it prevents data races at compile time. If two threads could access the same data unsafely, the code simply won't compile. This is what the Rust team calls "fearless concurrency."
Rust enforces thread safety at compile time. Arc (atomic reference count) + Mutex provides shared state; channels provide message passing.
use std::thread;
use std::sync::{Arc, Mutex};
use std::sync::mpsc; // multiple producer, single consumer
fn main() {
// --- Threads with shared state via Arc<Mutex<T>> ---
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Counter: {}", *counter.lock().unwrap()); // 10
// --- Message passing with channels ---
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "thread"];
for msg in messages {
tx.send(msg).unwrap();
thread::sleep(std::time::Duration::from_millis(100));
}
});
for received in rx {
println!("Got: {}", received);
}
}
Rust's thread safety guarantees come from two marker traits: Send (a type can be transferred to another thread) and Sync (a type can be shared between threads). The compiler automatically checks these — you can't accidentally pass a non-thread-safe type across thread boundaries.
Real-World Example: A CLI Task Manager
Let's put everything together and build a small command-line task manager. It demonstrates structs, enums, pattern matching, error handling, and file I/O — the patterns you'll use in every real Rust project.
use std::fs;
use std::io::{self, BufRead, Write};
#[derive(Debug)]
struct Task {
id: usize,
title: String,
done: bool,
}
impl Task {
fn new(id: usize, title: &str) -> Self {
Task {
id,
title: title.to_string(),
done: false,
}
}
fn to_line(&self) -> String {
format!("{}|{}|{}", self.id, self.title, self.done)
}
}
fn load_tasks(path: &str) -> Vec<Task> {
let Ok(content) = fs::read_to_string(path) else {
return vec![];
};
content
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, '|').collect();
if parts.len() == 3 {
Some(Task {
id: parts[0].parse().ok()?,
title: parts[1].to_string(),
done: parts[2] == "true",
})
} else {
None
}
})
.collect()
}
fn save_tasks(path: &str, tasks: &[Task]) -> io::Result<()> {
let mut file = fs::File::create(path)?;
for task in tasks {
writeln!(file, "{}", task.to_line())?;
}
Ok(())
}
fn main() {
let path = "tasks.txt";
let mut tasks = load_tasks(path);
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("add") => {
let title = args[2..].join(" ");
let id = tasks.len() + 1;
tasks.push(Task::new(id, &title));
save_tasks(path, &tasks).unwrap();
println!("Added task #{}: {}", id, title);
}
Some("done") => {
if let Some(id) = args.get(2).and_then(|s| s.parse::<usize>().ok()) {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
task.done = true;
save_tasks(path, &tasks).unwrap();
println!("Task #{} marked as done", id);
} else {
eprintln!("Task {} not found", id);
}
}
}
Some("list") | None => {
for task in &tasks {
let status = if task.done { "✓" } else { "○" };
println!("[{}] #{} {}", status, task.id, task.title);
}
if tasks.is_empty() {
println!("No tasks. Add one: cargo run -- add Buy groceries");
}
}
Some(cmd) => eprintln!("Unknown command: {}", cmd),
}
}
Essential Cargo Commands Cheatsheet
| Command | Description |
|---|---|
cargo new myapp | Create a new binary project |
cargo new mylib --lib | Create a library crate |
cargo build | Compile in debug mode |
cargo build --release | Optimised production build |
cargo run | Build and run |
cargo run -- add Buy milk | Pass arguments to your program |
cargo test | Run all tests |
cargo check | Type-check without producing a binary (fast) |
cargo clippy | Run the linter for idiomatic suggestions |
cargo fmt | Format code with rustfmt |
cargo doc --open | Generate and open documentation |
cargo add serde | Add a dependency (cargo-edit) |
cargo update | Update all dependencies |
Where to Go From Here
You've now seen the concepts that define Rust: ownership, borrowing, enums with pattern matching, and error handling with Result. These ideas take practice to internalize, but once they click, writing Rust feels remarkably productive — the compiler becomes your pair programmer, catching bugs before they reach production.
Recommended Learning Path
- The Rust Book — the official guide at doc.rust-lang.org/book, available free online. Chapters 10 (generics, traits, lifetimes) and 13 (iterators, closures) are especially important.
- Rustlings — small exercises that teach syntax and ownership hands-on. Run
cargo install rustlingsto start. - Iterators and closures — Rust's functional-style collection processing is powerful and idiomatic. Learn
map,filter,fold, andcollect. - Traits — Rust's answer to interfaces. They enable polymorphism without inheritance and are the backbone of every major Rust library.
- async/await — Rust's asynchronous programming model with
tokioorasync-stdis the foundation for high-performance web services. - Axum or Actix-Web — build production-ready HTTP APIs in Rust. Both are battle-tested and extremely fast.
"Rust's goal is not to be the easiest language to learn. It's to be the language where the difficulty you encounter is the actual difficulty of the problem, not an artifact of the language itself."
— Rust community
The investment in learning Rust's ownership model pays dividends for years: programs that don't crash due to null pointer dereferences, no memory leaks, no data races, and performance that rivals hand-optimised C. Start with small projects, lean on the compiler's error messages, and don't hesitate to read the generated documentation with cargo doc --open. The community is welcoming, the tooling is exceptional, and the language keeps getting better.