Variables #

Variables in Rust look ordinary on the surface — you write let x = 5 and you’re done. But behind that simple syntax, Rust enforces rules that don’t exist in other languages: variables are immutable by default, an ownership system determines when memory is freed, and borrowing rules prevent concurrency bugs before the program even runs. All of these rules are enforced by the compiler, not the runtime — which means if your code compiles, a huge class of memory bugs has already been structurally eliminated. This article covers how Rust variables work, from the simplest declaration to the nuances of shadowing, scope, and their interaction with the memory system.

Declaring with let #

Every local variable in Rust is declared with the let keyword. Without any additions, the declared variable is immutable — its value can’t be changed after it’s first set.

fn main() {
    let x = 5;
    println!("x = {}", x);

    // ANTI-PATTERN: trying to change an immutable variable
    x = 6; // error[E0384]: cannot assign twice to immutable variable `x`
}

Rust’s compiler error messages are very descriptive — they don’t just tell you what’s wrong, they also point at the line that first set the value and suggest the fix (let mut).

Immutability by default isn’t just a stylistic choice. It pushes you to think explicitly: does this data need to change? If not, leave it immutable. This reduces the amount of state you have to track while reading code, and lets the compiler apply more aggressive optimizations.

Declaration Without Initialization #

Rust lets you declare a variable without assigning a value right away, as long as the variable is definitely initialized before it’s first used. The compiler tracks this flow statically — it doesn’t just require initialization at declaration time.

fn main() {
    let nilai; // declaration without initialization — valid

    // ANTI-PATTERN: using it before initialization
    // println!("{}", nilai); // error[E0381]: used binding `nilai` isn't initialized

    nilai = 42; // initialization
    println!("nilai = {}", nilai); // ✓ now valid

    // Common pattern: conditional initialization
    let status;
    let kode = 200;

    if kode == 200 {
        status = "OK";
    } else {
        status = "Error";
    }

    // ✓ The compiler verifies that status is definitely initialized
    // on every execution path before it's used here
    println!("Status: {}", status);
}

Mutability with mut #

Add mut after let to create a variable whose value can be changed.

fn main() {
    let mut skor = 0;
    println!("Initial score: {}", skor);

    skor += 10;
    skor += 25;
    println!("Final score: {}", skor); // 35

    let mut nama = String::from("Budi");
    nama.push_str(" Santoso"); // a method that requires &mut self
    println!("Full name: {}", nama);
}

mut only applies to a single binding — it doesn’t automatically “spread” to the data behind references. This matters when working with references:

fn main() {
    let mut angka = 10;

    // &mut angka — a mutable reference to angka
    let r = &mut angka;
    *r += 5; // dereference to change the value

    println!("angka = {}", angka); // 15
}

When to Choose mut vs Immutable #

Use mut only when the variable actually needs to change. This isn’t about performance — the Rust compiler optimizes both well. It’s about communicating with the reader: a mut variable signals “this value will change at some point”, while an ordinary variable guarantees “this value stays stable from here to the end of the scope”.

// ANTI-PATTERN: declaring mut when it's never changed
fn luas_persegi(sisi: f64) -> f64 {
    let mut hasil = sisi * sisi; // the compiler warns: variable does not need to be mutable
    hasil // returned directly without being changed
}

// CORRECT: immutable because it doesn't change
fn luas_persegi(sisi: f64) -> f64 {
    let hasil = sisi * sisi;
    hasil
}

// CORRECT: or directly as an expression
fn luas_persegi(sisi: f64) -> f64 {
    sisi * sisi
}

Type Inference and Type Annotations #

Rust is a statically typed language — the type of every variable is known at compile time. But you don’t always have to write it out, because the Rust compiler has a powerful type inference system.

fn main() {
    // Type inference — the compiler infers the type from the value
    let a = 42;        // i32 (the default for integer literals)
    let b = 3.14;      // f64 (the default for float literals)
    let c = true;      // bool
    let d = 'z';       // char
    let e = "halo";    // &str

    // Explicit type annotation — written after the variable name with a colon
    let f: u8 = 255;
    let g: f32 = 2.5;
    let h: i64 = -1_000_000;

    // Literal suffixes — an annotation alternative for numeric literals
    let i = 42u8;
    let j = 3.14f32;
    let k = 1_000_000i64;

    println!("{} {} {} {} {}", a, b, c, d, e);
}

Type annotations are required in these situations:

fn main() {
    // 1. When the compiler can't infer from context
    let angka: i32; // without an initial value, the type must be explicit
    angka = 10;

    // 2. When there's ambiguity — e.g. parsing a string into a number
    let parsed: u32 = "42".parse().unwrap(); // without an annotation, the compiler doesn't know which type you want

    // 3. When you want a type different from the default
    let kecil: i8 = 100; // the default is i32, but we want i8

    // 4. For generic collections
    let daftar: Vec<String> = Vec::new();

    println!("{} {} {} {:?}", angka, parsed, kecil, daftar);
}

Shadowing #

Shadowing is a feature where you declare a new variable with the same name using let again. The old variable is “hidden” by the new one for the rest of the scope.

fn main() {
    let x = 5;
    println!("first x: {}", x); // 5

    let x = x + 1; // a NEW variable named x, with the value 6
    println!("second x: {}", x);   // 6

    {
        let x = x * 2; // yet another NEW variable, only inside this block
        println!("x in block: {}", x); // 12
    }

    println!("x after block: {}", x); // 6 — the inner block's x no longer exists
}

Shadowing vs Mutation — The Critical Difference #

Shadowing and mut look similar but are fundamentally different:

fn main() {
    // Shadowing: creates a NEW variable — can change the type
    let spasi = "   "; // type: &str
    let spasi = spasi.len(); // type: usize — a DIFFERENT type, valid with shadowing
    println!("Number of spaces: {}", spasi);

    // ANTI-PATTERN: trying to change the type with mut — impossible
    let mut teks = "hello"; // type: &str
    teks = teks.len(); // error[E0308]: mismatched types — expected `&str`, found `usize`
}
flowchart TD
    A{Need to change the variable's value?}
    A -- Yes --> B{Need to change the type too?}
    A -- No --> F[Use an ordinary immutable variable]
    B -- Yes --> C[Use shadowing\nlet x = transform_x]
    B -- No --> D{Does the change happen many times\nor inside a loop?}
    D -- Yes --> E[Use mut\nlet mut x = ...]
    D -- No --> C

Idiomatic Uses of Shadowing #

Shadowing is very useful for step-by-step transformations of the same value — without having to invent a new variable name at every step.

fn proses_input(input: &str) -> u32 {
    // Every step "refines" the input under the same name
    let input = input.trim();              // &str → &str (cleaned of whitespace)
    let input = input.to_lowercase();      // &str → String
    let input = input.replace('-', "");    // String → String (remove hyphens)
    let input: u32 = input.parse().expect("Not a valid number");  // String → u32

    input
}

fn main() {
    let hasil = proses_input("  123-456  ");
    println!("Result: {}", hasil); // 123456
}

Without shadowing, you’d have to create input_trimmed, input_lower, input_cleaned, input_parsed — names that don’t add any clarity at all.


Scope and Automatic Drop #

A scope is the range of code where a variable is valid and usable. In Rust, scope has a direct implication for memory: when a variable leaves its scope, Rust calls the drop function automatically to free the memory the variable allocated. There’s no garbage collector — memory is freed at a deterministic, predictable point.

fn main() {
    let a = String::from("luar"); // a enters scope

    {
        let b = String::from("dalam"); // b enters scope
        println!("a = {}, b = {}", a, b); // both are valid here
    } // b leaves scope → Rust calls drop(b) → b's memory is freed

    println!("a = {}", a); // a is still valid
    // println!("b = {}", b); // error: b doesn't exist in this scope
} // a leaves scope → drop(a) → a's memory is freed
stateDiagram-v2
    [*] --> Deklarasi: let x = nilai
    Deklarasi --> Valid: In scope
    Valid --> Valid: Used, modified (if mut)
    Valid --> Drop: Leaves scope
    Drop --> [*]: Memory freed automatically

Scope as a Memory Control Tool #

You can use {} blocks explicitly to control when a value gets dropped — useful for resources like database connections, files, or locks.

fn main() {
    // Scenario: a mutex lock is only needed for certain operations
    use std::sync::Mutex;
    let data = Mutex::new(vec![1, 2, 3]);

    // ANTI-PATTERN: holding the lock until the end of the function
    let mut guard = data.lock().unwrap();
    guard.push(4);
    // The lock stays active until main ends — blocking other threads longer than necessary

    // CORRECT: limit the lock's scope with an explicit block
    {
        let mut guard = data.lock().unwrap();
        guard.push(4);
    } // the lock is released here — other threads can access the data sooner

    println!("Done");
}

Ownership — Variables as Data Owners #

Every value in Rust has exactly one owner at a time. When ownership moves, the old owner can no longer access the value. This rule is enforced by the compiler, not the runtime.

Move Semantics #

For types allocated on the heap (like String, Vec, etc.), assignment moves ownership — it doesn’t copy the data.

fn main() {
    let s1 = String::from("halo");
    let s2 = s1; // ownership MOVES from s1 to s2

    // ANTI-PATTERN: using s1 after it's been moved
    println!("{}", s1); // error[E0382]: borrow of moved value: `s1`

    // CORRECT: use s2
    println!("{}", s2); // ✓
}

Why does Rust do this? Because if two variables pointed at the same heap data, who would be responsible for freeing the memory? With the one-owner rule, there’s no ambiguity — s2 is responsible, and when s2 leaves scope, the memory is freed exactly once.

Copy Types — Types That Aren’t Moved #

Types that live entirely on the stack (size known at compile time, cheap to copy) implement the Copy trait. For these types, assignment copies the value — ownership doesn’t move.

fn main() {
    // Copy types: integers, floats, bools, chars, tuples of Copy types
    let x = 5;
    let y = x; // COPY — x stays valid
    println!("x = {}, y = {}", x, y); // both valid ✓

    let a = true;
    let b = a;
    println!("{} {}", a, b); // ✓

    // NON-Copy types: String, Vec, Box, and other heap types
    let s1 = String::from("halo");
    let s2 = s1; // MOVE — s1 is no longer valid
    // println!("{}", s1); // error ✗
    println!("{}", s2); // ✓
}
TypeAssignment BehaviorReason
i8, i16, i32, i64, i128, isizeCopyFixed size on the stack
u8, u16, u32, u64, u128, usizeCopyFixed size on the stack
f32, f64CopyFixed size on the stack
bool, charCopyFixed size on the stack
(T, U) if T and U are CopyCopyAll elements on the stack
[T; N] if T is CopyCopyAll elements on the stack
StringMoveData on the heap, dynamic size
Vec<T>MoveData on the heap, dynamic size
Box<T>MovePointer to the heap

Clone — Explicit Copying #

If you need two independent copies of a heap value, use .clone():

fn main() {
    let s1 = String::from("halo");
    let s2 = s1.clone(); // make a complete copy on the heap

    // Both are valid and independent
    println!("s1 = {}, s2 = {}", s1, s2);

    // ANTI-PATTERN: redundant cloning on Copy types
    let x = 5;
    let y = x.clone(); // not wrong, but redundant — just write `let y = x`
}
.clone() performs a deep copy that can be expensive for large data. Don’t use .clone() as an escape hatch from borrow checker errors before really understanding whether a copy is needed. Often borrowing (&T) is the more appropriate solution.

Borrowing — Lending Without Taking Ownership #

Borrowing lets you give access to a value without moving its ownership. You “lend” the value — the borrower can use it, but the original owner stays responsible for the drop.

Immutable Borrow (&T) #

fn panjang_string(s: &String) -> usize {
    s.len()
    // s leaves scope, but isn't dropped — because it was borrowed, not owned
}

fn main() {
    let s = String::from("halo dunia");

    let panjang = panjang_string(&s); // lend s, don't move it
    println!("'{}' has {} characters", s, panjang); // s is still valid ✓
}

Many immutable borrows can coexist — reading data in parallel doesn’t cause problems:

fn main() {
    let s = String::from("data");

    let r1 = &s;
    let r2 = &s;
    let r3 = &s;

    // All three are valid at the same time — they only read
    println!("{} {} {}", r1, r2, r3);
}

Mutable Borrow (&mut T) #

A mutable borrow gives read and write access to a value without taking ownership of it. The rules are strict: only one mutable borrow can be active at a time, and no immutable borrow can coexist with a mutable one.

fn tambahkan_kata(s: &mut String) {
    s.push_str(", dunia");
}

fn main() {
    let mut s = String::from("halo");
    tambahkan_kata(&mut s);
    println!("{}", s); // "halo, dunia"

    // ANTI-PATTERN: two mutable borrows at the same time
    let mut data = String::from("test");
    let r1 = &mut data;
    let r2 = &mut data; // error[E0499]: cannot borrow `data` as mutable more than once at a time
    println!("{} {}", r1, r2);

    // ANTI-PATTERN: immutable and mutable borrows at the same time
    let mut nilai = 5;
    let baca = &nilai;      // immutable borrow
    let tulis = &mut nilai; // error[E0502]: cannot borrow `nilai` as mutable because it is also borrowed as immutable
    println!("{} {}", baca, tulis);
}

These rules prevent data races — the condition where two parts of code access the same data at the same time and at least one of them writes. Data races cause bugs that are very hard to track down in other languages; in Rust, they’re impossible because the compiler rejects them.


Slices — References to Part of the Data #

A slice is a reference to part of a collection — not a copy, not an owner, but a view into a specific portion of existing data. A slice is always a borrow.

String Slices (&str) #

fn main() {
    let kalimat = String::from("halo dunia");

    // Slices with byte index ranges
    let kata_pertama = &kalimat[0..4];   // "halo"
    let kata_kedua = &kalimat[5..10];    // "dunia"

    // Ranges can be shortened
    let awal = &kalimat[..4];    // from index 0 — same as [0..4]
    let akhir = &kalimat[5..];   // to the end — same as [5..10]
    let semua = &kalimat[..];    // the whole string

    println!("{} | {} | {}", kata_pertama, kata_kedua, semua);
}
Indexes in a string slice refer to bytes, not characters. Slicing in the middle of a multibyte character (like non-ASCII Unicode characters) causes a runtime panic. To slice by characters, use an iterator: s.chars().take(n).collect::<String>().

Functions That Accept &str #

The &str type is more flexible than &String as a function parameter — it accepts both:

// ANTI-PATTERN: parameter too specific
fn cetak(s: &String) {
    println!("{}", s);
}

// CORRECT: &str is more generic — accepts &String, &str literals, and slices
fn cetak(s: &str) {
    println!("{}", s);
}

fn main() {
    let owned = String::from("from String");
    let literal = "from a string literal";

    cetak(&owned);    // &String converts to &str automatically ✓
    cetak(literal);   // &str directly ✓
    cetak(&owned[5..]); // slice ✓
}

Array Slices #

Slices also work on arrays and Vecs:

fn jumlahkan(slice: &[i32]) -> i32 {
    slice.iter().sum()
}

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let vec = vec![10, 20, 30, 40, 50];

    println!("Sum of arr: {}", jumlahkan(&arr));       // the whole array
    println!("Sum of middle 3: {}", jumlahkan(&arr[1..4])); // [2, 3, 4]
    println!("Sum of vec: {}", jumlahkan(&vec));       // the whole vec
}

Idiomatic Declaration Patterns #

A few variable declaration patterns appear often in idiomatic Rust code and are worth recognizing:

fn main() {
    // 1. Tuple destructuring
    let (x, y, z) = (1, 2.0, "tiga");
    println!("{} {} {}", x, y, z);

    // 2. Struct destructuring
    struct Titik { x: f64, y: f64 }
    let titik = Titik { x: 3.0, y: 4.0 };
    let Titik { x: px, y: py } = titik;
    println!("Point: ({}, {})", px, py);

    // 3. Ignore values with an underscore
    let (penting, _, juga_penting) = (1, 2, 3);
    println!("{} {}", penting, juga_penting);

    // 4. Intentionally unused variables — an underscore prefix prevents the warning
    let _debug_value = hitung_sesuatu(); // unused, but doesn't trigger a warning

    // 5. Binding in match and if let
    let angka = Some(42);
    if let Some(n) = angka {
        println!("The value: {}", n);
    }

    // 6. while let for iteration
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        println!("Popped: {}", top);
    }
}

fn hitung_sesuatu() -> i32 { 42 }

Summary #

  • Immutable by defaultlet x = 5 can’t be changed. Add mut only when the value really needs to change: it’s an explicit message to the code reader.
  • Declaration without initialization is valid — as long as the compiler verifies the variable is definitely initialized on every path before use.
  • Shadowing ≠ mutationlet x = x + 1 creates a new variable; it can change the type at the same time. Useful for step-by-step transformations under the same name.
  • Scope determines when memory is freed — there’s no garbage collector; drop is called automatically when a variable leaves scope, at a deterministic, predictable point.
  • Copy types vs Move types — integers, floats, bools, and chars are copied on assignment; String, Vec, and other heap types are moved. After a move, the old owner is invalid.
  • Clone for explicit copies.clone() creates an independent copy on the heap; use it only when a copy is actually needed, not as an escape hatch from the borrow checker.
  • Immutable borrows (&T) can be many at once — because parallel reads are safe. Mutable borrows (&mut T) are limited to one at a time and can’t coexist with any other borrow.
  • &str is better than &String as a parameter&str accepts string literals, &String, and slices all at once; more flexible at no extra cost.
  • Destructuring is an idiomatic Rust pattern — it works on tuples, structs, enums, and in let, match, if let, and while let contexts.

← Previous: Comments   Next: Constants →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact