Variables and Mutability

Variable Declaration with let

The let statement declares a variable binding: it introduces a name, gives it a value, and makes that name usable in the rest of the enclosing block. let is the only way to create a local variable in Rust.

fn main() {
    let count = 10;
    let price = 4.99;
    let initial = 'R';
    let active = true;
    let language = "Rust";
    println!("{count} {price} {initial} {active} {language}");
}
10 4.99 R true Rust

Anatomy of a let Statement

let  name  : Type  =  initializer ;
 |    |      |         |          |
 |    |      |         |          +-- statement terminator (required)
 |    |      |         +------------- any expression that produces the value
 |    |      +----------------------- optional type annotation
 |    +------------------------------ a pattern (usually just a name)
 +----------------------------------- keyword
Part Required Meaning
let yes Keyword that starts the declaration
pattern yes Usually a single name. It can also be a pattern that takes a value apart, such as (a, b)
: Type no Declares the type explicitly. When omitted, the compiler infers it
= initializer no The value to store. When omitted, the variable is declared but uninitialized
; yes Ends the statement

The part between let and = is a pattern, not only a name. A plain name is the simplest pattern. Patterns that destructure tuples, arrays, and structs are covered in the destructuring section; this file uses plain names.

What a Binding Is

Rust uses the word binding because let binds a name to a value. In compiled code, a local variable normally occupies a slot on the stack, and the name is how the source code refers to that slot.

fn main() {
    let count = 10;
    let price = 4.99;
    let active = true;
}

Stack frame of main
+-----------------------+
| count   (i32)  = 10   |   4 bytes
| price   (f64)  = 4.99 |   8 bytes
| active  (bool) = true |   1 byte
+-----------------------+

The compiler is free to place values in registers or to remove them entirely, so the picture shows the model, not a guarantee about the generated machine code.

Binding a value to a new name copies or moves it, depending on the type:

fn main() {
    let x = 5;
    let y = x; // i32 is Copy: x stays valid
    let s = String::from("a");
    let t = s; // String is not Copy: the value moves to t, s is no longer usable
    println!("{x} {y} {t}");
}
5 5 a

Type Inference and Annotations

When there is no type annotation, the compiler infers the type from the initializer and from how the variable is used later.

  • An integer literal with no other constraint becomes i32.
  • A floating-point literal with no other constraint becomes f64.

An annotation after the name overrides the default:

fn main() {
    let small: u8 = 200;
    let default_int = 200;
    let ratio: f32 = 0.5;
    let default_float = 0.5;
    println!("{small} {default_int} {ratio} {default_float}");
    println!("{}", std::mem::size_of_val(&small));
    println!("{}", std::mem::size_of_val(&default_int));
    println!("{}", std::mem::size_of_val(&ratio));
    println!("{}", std::mem::size_of_val(&default_float));
}
200 200 0.5 0.5
1
4
4
8

Inference looks at later uses of the variable as well. In the next example guess has no annotation, yet it becomes a u64 because it is multiplied and stored into a u64:

fn main() {
    let guess = "42".parse().unwrap_or(0);
    let doubled: u64 = guess * 2;
    println!("{doubled}");
    let parsed = "7".parse::<u8>().unwrap();
    println!("{parsed}");
}
84
7

The second line shows the other way to choose a type: a turbofish (::<u8>) on the method call.

When the compiler cannot determine a type from anything in the function, it stops and asks for an annotation:

// ❌ does not compile
fn main() {
    let numbers = Vec::new();
    println!("{}", numbers.len());
}
error[E0282]: type annotations needed for `Vec<_>`
 --> a9.rs:3:9
  |
3 |     let numbers = Vec::new();
  |         ^^^^^^^   ---------- type must be known at this point
  |
help: consider giving `numbers` an explicit type, where the type for type parameter `T` is specified
  |
3 |     let numbers: Vec<T> = Vec::new();
  |                ++++++++

Every variable has exactly one type, fixed at compile time. A variable is never "changed" to another type; a different type needs a different variable (see shadowing).

// ❌ does not compile
fn main() {
    let a = 5;
    let b: i64 = a;
    let c: i32 = a;
    println!("{b} {c}");
}
error[E0308]: mismatched types
 --> a11.rs:5:18
  |
5 |     let c: i32 = a;
  |            ---   ^ expected `i32`, found `i64`
  |            |
  |            expected due to this
  |
help: you can convert an `i64` to an `i32` and panic if the converted value doesn't fit
  |
5 |     let c: i32 = a.try_into().unwrap();
  |                   ++++++++++++++++++++

Here the use let b: i64 = a fixes a as i64, so the second use conflicts with it.

The Initializer Is an Expression

Anything that produces a value can follow the =: literals, arithmetic, function calls, method calls, closures, if, match, and blocks.

fn compute() -> i32 {
    6 * 7
}

fn main() {
    let answer = compute();
    let text = String::from("hello");
    let list = vec![1, 2, 3];
    let pair = (answer, text.len());
    let closure = |x: i32| x + 1;
    println!("{answer} {text} {list:?} {pair:?} {}", closure(1));
}
42 hello [1, 2, 3] (42, 5) 2

Because if and blocks are expressions, a variable can be initialized from a decision or from several steps of computation:

fn main() {
    let width = 4;
    let height = 5;
    let area = width * height;
    let label = if area > 10 { "large" } else { "small" };
    let perimeter = {
        let sum = width + height;
        sum * 2
    };
    println!("{area} {label} {perimeter}");
}
20 large 18

The block that computes perimeter ends in sum * 2 with no semicolon. That final expression is the block's value. The variable sum exists only inside the block.

let Is a Statement, Not an Expression

let does not produce a value, so it cannot appear where an expression is expected. This is why Rust has no chained declarations such as let a = let b = 5;.

// ❌ does not compile
fn main() {
    let x = (let y = 5);
    println!("{y}");
}
error: expected expression, found `let` statement
 --> a4.rs:3:14
  |
3 |     let x = (let y = 5);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

The note refers to if let and while let, where let is part of a condition. That is a different construct from a let statement.

Each let declares one pattern. There is no comma-separated form:

// ❌ does not compile
fn main() {
    let a = 1, b = 2;
}
error: expected one of `.`, `;`, `?`, `else`, or an operator, found `,`
 --> b7.rs:3:14
  |
3 |     let a = 1, b = 2;
  |              ^ expected one of `.`, `;`, `?`, `else`, or an operator

To declare two variables in one statement, use a tuple pattern: let (a, b) = (1, 2);.

The semicolon is mandatory:

// ❌ does not compile
fn main() {
    let x = 5
    println!("{x}");
}
error: expected `;`, found `println`
 --> a8.rs:3:14
  |
3 |     let x = 5
  |              ^ help: add `;` here
4 |     println!("{x}");
  |     ------- unexpected token

Declaring Without a Value

The initializer can be left out. The variable then has a name and a type but no value, and it must be assigned before it is read.

fn main() {
    let total: i32;
    total = 5;
    println!("{total}");
}
5

Without an initializer, the compiler needs another way to learn the type, so either an annotation or a later assignment must fix it. Declaring without a value, deferred initialization, and the rules the compiler applies are covered in the section on uninitialized variables.

Scope: Where a Binding Is Visible

A binding is visible from the end of its let statement to the end of the block that contains it. It cannot be used before its declaration:

// ❌ does not compile
fn main() {
    println!("{total}");
    let total = 10;
}
error[E0425]: cannot find value `total` in this scope
 --> b1.rs:3:16
  |
3 |     println!("{total}");
  |                ^^^^^ not found in this scope

It also cannot be used after the block that declared it has ended:

// ❌ does not compile
fn main() {
    {
        let inner = 1;
    }
    println!("{inner}");
}
error[E0425]: cannot find value `inner` in this scope
 --> b8.rs:6:16
  |
6 |     println!("{inner}");
  |                ^^^^^
  |
help: the binding `inner` is available in a different scope in the same function
 --> b8.rs:4:13
  |
4 |         let inner = 1;
  |             ^^^^^

Scope and the lifetime of a binding are covered in detail in their own section.

Refutable Patterns and let-else

The pattern in a let statement must be irrefutable: it must match every possible value of the type. A plain name always matches. A pattern such as Some(n) does not, because the value could be None.

// ❌ does not compile
fn main() {
    let Some(n) = "42".parse::<i32>().ok();
    println!("{n}");
}
error[E0005]: refutable pattern in local binding
 --> a13.rs:3:9
  |
3 |     let Some(n) = "42".parse::<i32>().ok();
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let...else` to handle the variant that isn't matched
  |
3 |     let Some(n) = "42".parse::<i32>().ok() else { todo!() };
  |                                            ++++++++++++++++

A let ... else statement accepts a refutable pattern. When the pattern does not match, the else block runs, and that block must diverge: it must end the current path with return, break, continue, panic!, or another expression of type !.

fn main() {
    let Some(n) = "42".parse::<i32>().ok() else {
        println!("not a number");
        return;
    };
    println!("{n}");
}
42

On a match, n is bound and stays in scope for the rest of the block, with no extra nesting.

Unused Variables

A variable that is declared and never read produces a warning:

fn main() {
    let unused = 5;
    let _ignored = 6;
    let _ = 7;
    println!("done");
}
warning: unused variable: `unused`
 --> a5.rs:2:9
  |
2 |     let unused = 5;
  |         ^^^^^^ help: if this is intentional, prefix it with an underscore: `_unused`
  |
  = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default

warning: 1 warning emitted

done
Form Binds a name Warns when unused Notes
let unused = 5; yes yes Normal binding
let _unused = 5; yes no A leading underscore marks the name as intentionally unused. The variable still exists and can be read
let _ = 5; no no _ is a wildcard pattern. It binds nothing, so there is no variable to use

The difference between _name and _ matters for values that have cleanup code. let _guard = value; keeps the value alive until the end of the scope. let _ = value; does not bind the value, so a temporary created by the expression is dropped at the end of that statement.

Variable Names

A variable name is an identifier. Rules and conventions:

  • It may contain letters, digits, and underscores, and must not start with a digit.
  • Non-ASCII letters are allowed, but ASCII is the norm.
  • Local variables use snake_case. Other casing compiles but produces a warning:
fn main() {
    let userName = "ada";
    println!("{userName}");
}
warning: variable `userName` should have a snake case name
 --> b2.rs:2:9
  |
2 |     let userName = "ada";
  |         ^^^^^^^^ help: convert the identifier to snake case: `user_name`
  |
  = note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default

ada

Keywords cannot be used as names:

// ❌ does not compile
fn main() {
    let type = "text";
    println!("{type}");
}
error: expected identifier, found keyword `type`
 --> b3.rs:3:9
  |
3 |     let type = "text";
  |         ^^^^ expected identifier, found keyword
  |
help: escape `type` to use it as an identifier
  |
3 |     let r#type = "text";
  |         ++

A raw identifier (r# followed by the name) lets a keyword serve as an identifier. It is rarely needed in new code; choosing a different name is usually clearer.

fn main() {
    let r#type = "text";
    let 名前 = "unicode";
    let _count = 1;
    println!("{} {} {}", r#type, 名前, _count);
}
text unicode 1