On this page

Variables, mutability, and primitive types

14 min read TextCh. 1 — Rust Fundamentals

Variables in Rust: immutable by default

One of the first surprises for developers coming from other languages is that variables are immutable by default in Rust. When you declare let x = 5, the value of x cannot change.

This is not a limitation — it is a deliberate design feature. Default immutability helps the compiler make optimizations, makes it easier to reason about code, and prevents a whole category of bugs where a value changes unexpectedly.

fn main() {
    let x = 5;
    x = 6; // Error: cannot assign twice to immutable variable `x`
}

The Rust compiler produces a clear error message:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:3:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

Note how the compiler even suggests the solution.

Mutable variables with `mut`

When you need a variable to change, add the mut keyword:

fn main() {
    let mut temperature = 20.0_f64;
    println!("Initial temperature: {temperature}°C");
    
    temperature += 5.0;
    println!("Temperature increased: {temperature}°C");
    
    temperature = temperature * 1.8 + 32.0;
    println!("In Fahrenheit: {temperature}°F");
}

Mutability is explicit and intentional. When you read Rust code and see let mut, you immediately know that variable will change. When you see just let, you know it won't — which is valuable information for understanding program flow.

Constants with `const`

Constants are declared with const and always require an explicit type annotation. Additionally, they can only contain values computed at compile time:

// Convention: SCREAMING_SNAKE_CASE for constants
const SPEED_OF_LIGHT: u64 = 299_792_458; // meters per second
const PI: f64 = 3.14159265358979323846;
const APP_NAME: &str = "MyApplication";

fn main() {
    let radius = 5.0_f64;
    let area = PI * radius * radius;
    println!("Circle area: {area:.2}");
}

Constants are different from immutable variables:

  • They exist for the entire duration of the program (they don't have a scope like let)
  • They can be declared in global scope (outside functions)
  • Their value must be computable at compile time

Shadowing: re-declaring variables

Rust allows re-declaring a variable with let in the same scope. The new declaration "shadows" the previous one:

fn main() {
    let x = 5;
    
    // The second `let x` shadows the first
    let x = x + 1;
    
    {
        // This shadowing only exists in this block
        let x = x * 2;
        println!("x in inner block: {x}"); // 12
    }
    
    println!("x in outer scope: {x}"); // 6
}

The crucial difference between shadowing and mut is that shadowing can change the type:

fn main() {
    // First it's a text string
    let value = "42";
    println!("As text: {value}");
    
    // Now it's a number — same name, different type
    let value: i32 = value.parse().expect("Not a number");
    println!("As number: {value}");
    
    // Now it's a boolean
    let value = value > 0;
    println!("Is positive: {value}");
}

With mut this would be impossible — you cannot change the type of a mutable variable.

Scalar types in Rust

Integers

Rust has signed (i) and unsigned (u) integers of various sizes:

Type Range
i8 -128 to 127
i16 -32,768 to 32,767
i32 -2,147,483,648 to 2,147,483,647 (default)
i64 -9.2 × 10¹⁸ to 9.2 × 10¹⁸
i128 Enormous
isize Architecture-dependent (32 or 64 bits)
u8 0 to 255
u32 0 to 4,294,967,295
u64 0 to 18.4 × 10¹⁸
usize For indices and collection sizes
fn main() {
    let signed: i32 = -42;
    let unsigned: u32 = 42;
    let index: usize = 0; // For indexing arrays/vectors
    
    // Literals in different bases
    let decimal = 1_000_000;
    let hexadecimal = 0xFF;        // 255
    let octal = 0o77;              // 63
    let binary = 0b1111_0000;      // 240
    let byte: u8 = b'A';           // 65 (ASCII code)
    
    println!("{decimal} {hexadecimal} {octal} {binary} {byte}");
}

Floating point

fn main() {
    let f32_val: f32 = 3.14;       // 32-bit, less precision
    let f64_val: f64 = 3.14159265358979; // 64-bit, default
    
    // Scientific notation
    let avogadro = 6.022e23_f64;
    let electron = 1.6e-19_f64;
    
    println!("{f32_val:.2}");
    println!("{f64_val:.10}");
}

Booleans

fn main() {
    let is_rust_great: bool = true;
    let has_gc: bool = false;
    
    println!("Is Rust great? {is_rust_great}");
    println!("Has GC? {has_gc}");
    
    // Booleans occupy 1 byte in memory
    println!("Size of bool: {} byte(s)", std::mem::size_of::<bool>());
}

Characters

The char type in Rust represents a complete Unicode code point (4 bytes), not just ASCII:

fn main() {
    let letter: char = 'A';
    let emoji: char = '🦀';  // The Rust crab
    let chinese: char = '中';
    let accented: char = 'é';
    
    println!("{letter} {emoji} {chinese} {accented}");
    println!("Size of char: {} bytes", std::mem::size_of::<char>());
}

&str vs String

This distinction is fundamental in Rust and deserves special attention:

&str (string slice): A reference to text data owned by someone else. The text usually lives in the program binary or in some String on the heap. It is immutable and its size is known at compile time (or at runtime).

String: A dynamically-sized text type that owns its data on the heap. It is mutable and can grow or shrink.

fn main() {
    // &str — string literal, in the binary's data segment
    let greeting: &str = "Hello, Rust";
    
    // String — on the heap, owned and mutable
    let mut name = String::from("Rustacean");
    name.push_str(" the Crab");
    name.push('!');
    
    println!("{greeting}, {name}");
    
    // Conversions
    let s: String = greeting.to_string();
    let r: &str = &name; // String -> &str with &
    
    // len() on &str is bytes, not Unicode characters
    let text = "café";
    println!("Bytes: {}", text.len());           // 5 (é is 2 bytes in UTF-8)
    println!("Chars: {}", text.chars().count()); // 4
}

Type inference

Rust has sophisticated type inference. In most cases you don't need to annotate types explicitly:

fn main() {
    let x = 42;          // i32 by default
    let y = 3.14;        // f64 by default
    let z = true;        // bool
    let s = "hello";     // &str
    
    // When context requires it, annotate the type
    let numbers: Vec<i32> = Vec::new();
    let parsed: u64 = "100".parse().unwrap();
    
    // Or with turbofish when calling generic functions
    let parsed2 = "100".parse::<u64>().unwrap();
    
    println!("{x} {y} {z} {s} {parsed} {parsed2}");
}

With a solid grasp of variables and primitive types, you are ready to explore functions and compound types, which will let you structure more complex programs.

Shadowing vs mut
Shadowing with let allows changing a variable's type and applying chained transformations. With mut you can only change the value, never the type. Prefer shadowing when the transformation produces a conceptually different value.
Thousands separator with underscore
Rust allows using _ as a visual separator in numeric literals: 1_000_000 equals 1000000. This also works in binary: 0b1111_0000, hexadecimal: 0xFF_FF, and octal: 0o77.
rust
fn main() {
    // Default: immutable
    let x = 5;
    println!("x = {x}");
    // x = 6; // Compile error

    // With mut: mutable
    let mut counter = 0;
    counter += 1;
    counter += 1;
    println!("counter = {counter}"); // 2

    // Shadowing: re-declare with let
    let y = 10;
    let y = y * 2;    // shadows the previous
    let y = y + 3;    // shadows again
    println!("y = {y}"); // 23

    // Shadowing allows changing the type
    let spaces = "   ";           // &str
    let spaces = spaces.len();    // usize
    println!("spaces = {spaces}");

    // Constants: always immutable, require type
    const MAX_POINTS: u32 = 100_000;
    println!("maximum = {MAX_POINTS}");
}