Getting Started with Rust: rustup, Cargo, Ownership Basics and Your First Compiler Errors
Key takeaways
Rust is a systems language that checks memory safety at compile time instead of using a garbage collector. This first part installs the toolchain, walks through a Cargo project, and introduces immutable bindings, moves and borrowing through the actual compiler errors a beginner hits first.
Introduction: what Rust is trying to solve
Rust is a compiled systems programming language. It started as a Mozilla research project and is now developed by the Rust Project, an open-source organisation. Its central idea is that a large class of bugs familiar from C and C++ (use-after-free, double free, dangling pointers, data races between threads) can be ruled out by the compiler, before the program ever runs, without the runtime cost of a garbage collector.
It does this with ownership: every value has exactly one owner, the value is freed when its owner goes out of scope, and other code can only borrow it under rules the compiler checks. You pay for this up front. Code that would compile in C++ is rejected until you make the ownership clear, and the first weeks with Rust are mostly spent reading compiler errors. The payoff is that once it compiles, those classes of memory bugs are gone from the safe part of your program.
Other features follow the same philosophy: variables are immutable unless marked mut, there is no null (absence is an Option), numeric types never convert implicitly, and errors are values (Result) rather than exceptions. The tooling (compiler, package manager, formatter, linter and test runner) ships together.
| Aspect | Rust | C++ |
|---|---|---|
| Memory safety | Checked by the compiler in safe code | Programmer’s responsibility; sanitizers and static analysers help |
| Null | No null references; Option<T> | Null pointers allowed |
| Build and packages | Cargo, included with the toolchain | CMake, Meson and others; vcpkg, Conan for packages |
| Error handling | Result<T, E> and ?; panics for bugs | Exceptions, error codes, std::expected (C++23) |
Installation
Rust is installed and updated through rustup, which manages toolchains (stable, beta, nightly) and targets.
macOS / Linux:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Windows: download and run rustup-init.exe from rustup.rs. The default Windows toolchain uses the MSVC linker, so the installer asks you to install the Visual Studio C++ Build Tools if they are missing. Skipping that step leads to a linker 'link.exe' not found error on your first build.
rustc --version
cargo --version
rustup update # later, to upgrade to the newest stable release
A new stable Rust version comes out every six weeks, and updating is routine. Editions (2015, 2018, 2021, 2024) are how the language makes opt-in changes without breaking old code: each crate declares its edition in Cargo.toml, and crates of different editions work together.
For editor support, install the rust-analyzer extension for VS Code or the equivalent for your editor. It shows types inline and runs the compiler’s checks as you type, which makes the learning phase much faster.
Hello World with Cargo
cargo new hello_rust
cd hello_rust
cargo run
// src/main.rs (generated)
fn main() {
println!("Hello, world!");
}
cargo new creates a directory with a Cargo.toml, a src/main.rs, and a Git repository with a .gitignore that excludes target/. println! ends with ! because it is a macro, not a function: it checks the format string against its arguments at compile time, so println!("{} {}", x) is a compile error, not a runtime crash.
Cargo.toml
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2024"
[dependencies]
Recent versions of Cargo generate edition = "2024"; older tutorials show 2021, and the code in this post works on both. Adding a dependency is a line under [dependencies] (or cargo add rand); Cargo downloads it from crates.io, builds it, and records exact versions in Cargo.lock. Commit Cargo.lock for applications so everyone builds with the same versions.
Commands you will use daily
cargo check # type-check without producing a binary (fastest feedback)
cargo build # debug build into target/debug/
cargo run # build and run
cargo test # run unit and integration tests
cargo build --release # optimised build into target/release/
cargo fmt # format code
cargo clippy # extra lints for common mistakes
cargo check is worth making a habit: most of your time early on is spent getting code past the compiler, and check skips code generation, so it answers faster than build.
Variables, mutability and types
fn main() {
let x = 5; // immutable, type inferred as i32
let mut y = 5; // mutable
y = 6;
let z: i64 = 10; // explicit type
let x = x * 2; // shadowing: a new binding named x
println!("{x} {y} {z}"); // 10 6 10
}
Assigning to an immutable binding is an error, and the message tells you exactly what to change:
error[E0384]: cannot assign twice to immutable variable `x`
Immutability by default is about making the reader’s job easier: if a binding is not mut, you know it never changes, which is most of them. Shadowing (let x = x * 2;) is different from mutation: it creates a new variable that can even have a different type, which is handy for transformations like let input = input.trim();.
Numeric types do not convert implicitly
let a: i32 = 10;
let b: i64 = 5;
// println!("{}", a + b);
error[E0308]: mismatched types
expected `i32`, found `i64`
error[E0277]: cannot add `i64` to `i32`
C and C++ silently promote and convert integers, which is the source of many signed/unsigned and truncation bugs. Rust makes you write i64::from(a) + b (lossless, always compiles) or a as i64 + b. Be careful with as when narrowing: 300_i32 as u8 is 44, with no warning. Use u8::try_from(x) when the value might not fit.
Overflow, indexing and panics
let x: u8 = 255;
let y = x + 1; // with a runtime value
In a debug build this panics: thread 'main' panicked at ...: attempt to add with overflow. In a release build, overflow checks are off by default and the value wraps to 0. That difference is a real trap: a test suite run in debug mode catches overflow, and the same bug in production silently wraps. When wrapping or saturation is what you want, say so with wrapping_add, saturating_add or checked_add.
Array indexing is always bounds-checked:
thread 'main' panicked at src/main.rs:3:20:
index out of bounds: the len is 3 but the index is 5
A panic stops the thread in a controlled way, with a message and location, instead of reading or corrupting memory the way an out-of-bounds access in C can. Panics are meant for bugs. Expected failures (a missing file, bad user input) are returned as Result values, covered later in the series.
Scalar and compound types
let a: i8 = 127; // i8..i128, u8..u128, isize/usize
let big: u32 = 4_294_967_295;
let f: f64 = 3.14159; // f64 is the default float
let ok: bool = true;
let emoji: char = '😀'; // a Unicode scalar value, 4 bytes
let tup: (i32, f64, char) = (500, 6.4, 'A');
let (p, q, r) = tup; // destructuring
println!("{p} {q} {r} {}", tup.0); // 500 6.4 A 500
let arr = [1, 2, 3, 4, 5]; // fixed size, type [i32; 5]
let first = arr[0];
char is always 4 bytes and holds any Unicode scalar value, while a String stores text as UTF-8, where '😀' takes 4 bytes and 'A' takes one. That is why Rust does not let you index a string by position (s[0] does not compile) — a byte index may land in the middle of a character. The String vs &str comparison goes into this.
Functions
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon: this expression is the return value
}
fn main() {
let result = add(10, 20);
println!("result: {result}");
}
Parameter and return types are always written out; inference works inside function bodies, not across signatures. The last expression without a semicolon is the return value. Adding a semicolon by habit (a + b;) turns it into a statement, the function returns (), and the compiler reports mismatched types: expected i32, found (), usually with a hint to remove the semicolon.
Ownership in one page
Moves
fn main() {
let s1 = String::from("hello"); // heap-allocated string, owned by s1
let s2 = s1; // ownership moves to s2
// println!("{} {}", s1, s2);
println!("{s2}");
}
Uncommenting the println! gives the error almost every Rust programmer meets on day one:
error[E0382]: borrow of moved value: `s1`
|
| let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
| let s2 = s1;
| -- value moved here
| println!("{} {}", s1, s2);
| ^^ value borrowed here after move
help: consider cloning the value if the performance cost is acceptable
A String is a pointer, length and capacity on the stack plus the text on the heap. let s2 = s1; copies the stack part only, and if both names stayed usable, both would free the same heap buffer when they go out of scope: a double free. C++ solves this with copy constructors (which deep-copy) and move semantics (which leave the source in a “valid but unspecified” state that you may still accidentally use). Rust makes the move the default and forbids using the old name afterwards, checked at compile time.
Simple types like integers, bool and char implement the Copy trait, so let b = a; copies them and both stay usable. That is why this error only appears with types that own resources.
Borrowing
fn calculate_length(s: &String) -> usize {
s.len()
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // lend s1 without moving it
println!("{s1} has length {len}");
}
A reference (&s1) lets a function read a value without taking ownership, so s1 is still usable afterwards. (Idiomatic code would take &str rather than &String; part 2 explains why.)
Mutable borrows
fn change(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{s}"); // hello, world
}
The borrowing rules: you can have any number of shared references (&T) or exactly one mutable reference (&mut T) to a value at a time, never both. Two overlapping mutable borrows are rejected:
error[E0499]: cannot borrow `s` as mutable more than once at a time
| let r1 = &mut s;
| ------ first mutable borrow occurs here
| let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
| r1.push_str("a");
| -- first borrow later used here
Note the last line: a borrow lasts until the reference’s last use, not to the end of the block. If r1 were not used after r2 was created, the code would compile. This rule is what prevents data races across threads and also single-threaded bugs such as holding a reference into a Vec while pushing to it (which can reallocate and leave the reference dangling in C++).
My honest advice for the first weeks is not to fight the borrow checker with .clone() everywhere. The compiler’s help: consider cloning the value hint is correct as far as it goes, and cloning does make errors disappear, but the common failure mode for newcomers is a program full of clones that hides a design where ownership was never decided. Clone when you truly need an independent copy; when you find yourself cloning to satisfy the compiler, ask which function should own the value and which should borrow it. Part 2 of the series covers this in depth.
The other thing that trips up people coming from C++ is expecting let s2 = s1; to copy. I would read every E0382 error as the compiler pointing at a real question (“who owns this now?”) rather than as noise.
A small calculator with safe division
fn add(a: i32, b: i32) -> i32 { a + b }
fn subtract(a: i32, b: i32) -> i32 { a - b }
fn multiply(a: i32, b: i32) -> i32 { a * b }
fn divide(a: i32, b: i32) -> Option<i32> {
if b == 0 { None } else { Some(a / b) }
}
fn main() {
let a = 10;
let b = 5;
println!("{a} + {b} = {}", add(a, b));
println!("{a} - {b} = {}", subtract(a, b));
println!("{a} * {b} = {}", multiply(a, b));
for divisor in [b, 0] {
match divide(a, divisor) {
Some(q) => println!("{a} / {divisor} = {q}"),
None => println!("{a} / {divisor}: cannot divide by zero"),
}
}
}
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
10 / 5 = 2
10 / 0: cannot divide by zero
Integer division by zero in Rust panics with attempt to divide by zero rather than being undefined behaviour as in C. Returning Option<i32> makes the possibility of “no result” part of the function’s type, and match forces the caller to handle both cases; forgetting the None arm is a compile error (non-exhaustive patterns). The standard library already provides this as a.checked_div(b).
Next in the series
Rust ownership, borrowing and lifetimes expands the one-page ownership section above into the full rules, including the borrow-checker errors you will meet first.