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