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.

AspectRustC++
Memory safetyChecked by the compiler in safe codeProgrammer’s responsibility; sanitizers and static analysers help
NullNo null references; Option<T>Null pointers allowed
Build and packagesCargo, included with the toolchainCMake, Meson and others; vcpkg, Conan for packages
Error handlingResult<T, E> and ?; panics for bugsExceptions, 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.