let bindings

A let binding gives a value a name: let kwh = 350;. Rust calls it a binding rather than an assignment because, by default, the name is bound to that value for good. Trying to change it is a compile error:

fn main() {
    let readings = [350, 412, 120];
    let total = 0;
    for kwh in readings {
        total += kwh;
    }
    println!("total: {total} kWh");
}
example.rs:5:9: error[E0384]: cannot assign twice to immutable variable `total`: cannot assign twice to immutable variable

The full message points at both places and suggests the fix:

error[E0384]: cannot assign twice to immutable variable `total`
 --> example.rs:5:9
  |
3 |     let total = 0;
  |         ----- first assignment to `total`
4 |     for kwh in readings {
5 |         total += kwh;
  |         ^^^^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
3 |     let mut total = 0;
  |         +++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0384`.

The +++ under mut marks text the compiler proposes to add. A binding may also be declared first and given its value later, once: let total; … total = 350; is fine, and the compiler refuses any use before that first value, so a variable can never be read uninitialized.

Mutability with mut

The mut keyword makes a binding mutable: its value may be replaced, and the value may be changed through it, such as adding elements to a list:

fn main() {
    let readings = [350, 412, 120];
    let mut total = 0;
    for kwh in readings {
        total += kwh;
    }
    println!("total: {total} kWh");

    let mut queue = Vec::new();         // an empty, growable list
    queue.push("h0017");
    queue.push("h0042");
    println!("{queue:?}");
}
total: 882 kWh
["h0017", "h0042"]

mut belongs to the binding, not to the type: the same list is read-only through a plain let and changeable through let mut. The compiler also notices the opposite mistake, a mut that is never used. Normally that is a warning; the examples on these pages are compiled with -D warnings, which turns warnings into errors, as many teams do in their builds:

fn main() {
    let mut meter = "M-30004";
    println!("meter: {meter}");
}
example.rs:2:9: error: variable does not need to be mutable
warning: variable does not need to be mutable
 --> example.rs:2:9
  |
2 |     let mut meter = "M-30004";
  |         ----^^^^^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default

warning: 1 warning emitted

Unused variables get a similar warning. If a name is unused on purpose, start it with an underscore (_meter) and the compiler stays quiet.

Constants and statics

A const is a constant the compiler knows in full: its type must be written out, its value must be computable while compiling, and the compiler copies the value to every place it is used. A static is a single value at a fixed place in memory for the whole run of the program. Both may be declared outside functions, and both are named in UPPER_SNAKE_CASE (other names give a warning):

// Fixed tariff in ten-thousandths of a euro per kWh: 2700 = €0.2700
const FIXED_PRICE_PER_KWH: u64 = 2_700;
const HOURS_PER_YEAR: u64 = 365 * 24;          // computed by the compiler
static SUPPLIER: &str = "Kestrel Energy";

fn main() {
    println!("{SUPPLIER}: {FIXED_PRICE_PER_KWH} per kWh, {HOURS_PER_YEAR} hours a year");
}
Kestrel Energy: 2700 per kWh, 8760 hours a year

Use const for fixed numbers and settings; you rarely need static. A static mut, a global variable anyone may change, exists but can only be used inside unsafe code, and since edition 2024 even taking a reference to one is an error by default (the lint static_mut_refs). Older material uses it for counters and caches; modern code uses the safe types from Concurrency instead.

Shadowing

A new let with a name that already exists creates a second variable that hides the first from then on. This is shadowing, and in Rust it is a normal tool, not a mistake. Its typical use is a value that passes through several forms, such as text read from input that becomes a number:

use std::io;

fn main() {
    let mut line = String::new();
    io::stdin().read_line(&mut line).expect("could not read standard input");

    let kwh = line.trim();                                   // text without the newline
    let kwh: u32 = kwh.parse().expect("not a whole number"); // now a number
    let kwh = kwh * 1000;                                    // in watt-hours

    println!("{kwh} Wh");
}
350000 Wh

The three kwh variables are separate: the first is a piece of text (&str), the second and third are numbers (u32). Each one is immutable; no variable was ever changed. With mut you could not do this, because a mutable variable keeps its type. Without shadowing you would need names like kwh_text and kwh_number. .expect(…) stops the program with that message if the input cannot be read or is not a number; better ways to handle that are on Error Handling.

A shadowing variable lives until the end of its block, like any variable. Inside an inner block it hides the outer one only there:

fn main() {
    let tariff = "fixed";
    {
        let tariff = "dynamic";              // a new variable, only in this block
        println!("inside the block: {tariff}");
    }
    println!("after the block:  {tariff}");  // the outer one was never changed
}
inside the block: dynamic
after the block:  fixed

Integers, floats, bool and char

Rust's simple types each hold a single value. The integer types carry their size and sign in their name: i for signed (negative values allowed), u for unsigned, followed by the number of bits.

Type Size Range or meaning
i8 / u8 1 byte −128 to 127 / 0 to 255
i16 / u16 2 bytes −32,768 to 32,767 / 0 to 65,535
i32 / u32 4 bytes about ±2.1 billion / 0 to about 4.3 billion
i64 / u64 8 bytes about ±9.2 × 1018 / 0 to about 1.8 × 1019
i128 / u128 16 bytes about ±1.7 × 1038 / 0 to about 3.4 × 1038
isize / usize pointer size 8 bytes on 64-bit systems, 4 on 32-bit
f32 / f64 4 / 8 bytes IEEE 754 floating-point numbers
bool 1 byte true or false
char 4 bytes One Unicode character, such as 'K' or '€'

The fixed sizes mean the same thing on every platform, unlike C's int and long (compare fixed-width integers in C). The exception is usize, the unsigned integer as wide as a memory address on the target platform. It is the type of every length, size and index: readings.len() returns a usize, and an index into an array or list must be one. Its signed partner isize is rarely needed. The sizes below were measured on a 64-bit Linux machine (ARM64); on a 32-bit microcontroller usize is 4 bytes.

fn main() {
    println!("u8    {} byte", size_of::<u8>());
    println!("i32   {} bytes", size_of::<i32>());
    println!("u64   {} bytes", size_of::<u64>());
    println!("i128  {} bytes", size_of::<i128>());
    println!("usize {} bytes", size_of::<usize>());
    println!("f64   {} bytes", size_of::<f64>());
    println!("bool  {} byte", size_of::<bool>());
    println!("char  {} bytes", size_of::<char>());
    println!("u32 from {} to {}", u32::MIN, u32::MAX);
    println!("i64 from {} to {}", i64::MIN, i64::MAX);
}
u8    1 byte
i32   4 bytes
u64   8 bytes
i128  16 bytes
usize 8 bytes
f64   8 bytes
bool  1 byte
char  4 bytes
u32 from 0 to 4294967295
i64 from -9223372036854775808 to 9223372036854775807

A char is four bytes because it holds any Unicode code point except the surrogates (a "Unicode scalar value"), not a byte. Text is not an array of char: a String stores UTF-8 bytes, so "Größe".len() is 7, its length in bytes (see Collections and Strings).

Type inference and literals

You rarely have to write types for local variables: the compiler infers them from how the value is used (see type inference). When nothing narrows it down, a whole number becomes an i32 and a decimal number an f64:

use std::any::type_name_of_val;

fn main() {
    let meters = 400_000;          // no suffix, no annotation
    let price = 0.27;
    let initial = 'K';
    let pilot = true;

    println!("{meters}: {}", type_name_of_val(&meters));
    println!("{price}: {}", type_name_of_val(&price));
    println!("{initial}: {}", type_name_of_val(&initial));
    println!("{pilot}: {}", type_name_of_val(&pilot));
}
400000: i32
0.27: f64
K: char
true: bool

Underscores make long literals readable (400_000), and a suffix fixes the type (400_000u64, 2.5f32). Hexadecimal, octal and binary literals start with 0x, 0o and 0b: 0xF1F4, 0b1010_0001. Function parameters, return types, const and static always need their types written out; inference only works inside function bodies.

Tuples and arrays

Two built-in types group values. A tuple is a fixed group of values that may have different types, such as a meter and its reading; an array is a fixed number of values of one type, written [u32; 7] for seven u32 values:

fn main() {
    let reading: (&str, u32) = ("M-30004", 350);     // a tuple: fixed set, mixed types
    let (meter, kwh) = reading;                      // take it apart
    println!("{meter} used {kwh} kWh ({} kWh)", reading.1);

    let week: [u32; 7] = [11, 9, 12, 10, 13, 15, 14]; // an array: fixed length, one type
    println!("{} days, first {}, last {}", week.len(), week[0], week[6]);
    println!("{week:?}");
}
M-30004 used 350 kWh (350 kWh)
7 days, first 11, last 14
[11, 9, 12, 10, 13, 15, 14]

Tuple fields are numbered from 0 (reading.1). The length of an array is part of its type and can never change; for a list that grows, use a Vec (see Collections and Strings). Every index is checked while the program runs. get returns an Option for an index that may be missing; plain indexing panics:

fn main() {
    let week: [u32; 7] = [11, 9, 12, 10, 13, 15, 14];
    let day = std::env::args().count() + 6;   // 7: imagine it came from user input
    println!("{:?}", week.get(day));         // safe: None instead of a crash
    println!("{}", week[day]);               // checked: panics
}
None
thread 'main' (48213) panicked at example.rs:5:20:
index out of bounds: the len is 7 but the index is 7

std::env::args().count() is the number of command-line arguments including the program's own name, so 1 here. The detour keeps the compiler from seeing the bad index in advance: with a plain week[7] it would refuse to compile the program at all.

Integer overflow

A fixed-size integer can overflow: the true result of a calculation does not fit. Kestrel has about 400,000 meters; one reading per meter per hour for a year is 400,000 × 8,760 = 3,504,000,000, more than the 2,147,483,647 an i32 can hold. In a debug build, Rust inserts an integer overflow check into every arithmetic operation, and the program panics instead of continuing with a wrong number:

// The number of meters, as it would come from a database at run time
fn meter_count() -> i32 {
    std::env::args().count() as i32 * 400_000   // 400000 when run without arguments
}

fn main() {
    let meters = meter_count();
    println!("meters: {meters}");
    let meter_hours = meters * 8_760;            // one reading per meter per hour, for a year
    println!("meter-hours per year: {meter_hours}");
}
meters: 400000
thread 'main' (48213) panicked at example.rs:9:23:
attempt to multiply with overflow

A release build (cargo build --release, or rustc -O as here) leaves the checks out for speed. The result then wraps around in two's complement, and the same program prints a negative number of meter-hours:

// The number of meters, as it would come from a database at run time
fn meter_count() -> i32 {
    std::env::args().count() as i32 * 400_000   // 400000 when run without arguments
}

fn main() {
    let meters = meter_count();
    println!("meters: {meters}");
    let meter_hours = meters * 8_760;            // one reading per meter per hour, for a year
    println!("meter-hours per year: {meter_hours}");
}
meters: 400000
meter-hours per year: -790967296

Both behaviors are defined by the language: overflow is a bug in your program, but it is never undefined behavior as signed overflow is in C, where the compiler may assume it never happens (compare signed overflow in C). The debug panic is there to find the bug during testing. Two operations are checked in every build: division by zero and i32::MIN / -1 always panic.

Saying what should happen

Every integer type has methods that make the overflow behavior part of the code, in every build. They are Rust's version of C23's checked integer arithmetic:

fn main() {
    let level: u8 = 250;
    println!("checked:     {:?}", level.checked_add(10));    // None: did not fit
    println!("checked:     {:?}", level.checked_add(5));     // Some(255)
    println!("wrapping:    {}", level.wrapping_add(10));     // 260 - 256
    println!("saturating:  {}", level.saturating_add(10));   // stops at the maximum
    println!("overflowing: {:?}", level.overflowing_add(10)); // result and a flag
    println!("saturating:  {}", 0u8.saturating_sub(1));      // stops at the minimum
}
checked:     None
checked:     Some(255)
wrapping:    4
saturating:  255
overflowing: (4, true)
saturating:  0

checked_* returns an Option: the result in Some, or None if it did not fit, which the caller must handle. Applied to the Kestrel numbers, it shows which type is big enough:

fn main() {
    let meters: u32 = 400_000;
    println!("{:?}", 400_000_i32.checked_mul(8_760));     // too big for i32
    println!("{:?}", meters.checked_mul(8_760));            // fits in u32
    println!("{:?}", meters.checked_mul(8_760 * 1_000));    // in watt-hours: too big for u32
    let meters = u64::from(meters);
    println!("{}", meters * 8_760 * 1_000);                 // u64 has room
}
None
Some(3504000000)
None
3504000000000

The meter-hours fit in a u32 (at most 4,294,967,295), but not once they are multiplied by 1,000 for watt-hours; a u64 holds that easily. u64::from is a safe conversion, explained below.

Overflow the compiler can see

When the values are known while compiling, the compiler refuses the overflow outright:

fn main() {
    let level: u8 = 255 + 1;
    println!("{level}");
}
example.rs:2:21: error: this arithmetic operation will overflow: attempt to compute `u8::MAX + 1_u8`, which would overflow

A literal that does not fit its type, such as let b: u8 = 256;, is refused in the same way ("literal out of range for `u8`"). That is why the runtime examples above take their numbers from std::env::args(): with plain constants, the program would never compile.

Conversions

Rust converts no number from one type to another by itself, not even to a wider type. Where C silently promotes and converts (see implicit conversions in C), Rust stops:

fn main() {
    let kwh: u32 = 350;
    let price_per_kwh: u64 = 2_700;      // ten-thousandths of a euro
    let cost = kwh * price_per_kwh;
    println!("{cost}");
}
example.rs:4:22: error[E0308]: mismatched types: expected `u32`, found `u64`

There are two ways to convert. The as cast (value as u64) converts between number types and always succeeds, which means it silently changes values that do not fit:

fn main() {
    let big: i32 = 300;
    let negative: i32 = -1;
    let kwh: f64 = 3.99;

    println!("{}", big as u8);          // keeps the lowest 8 bits
    println!("{}", negative as u32);    // same bits, read as unsigned
    println!("{}", kwh as i32);         // drops the fraction
    println!("{}", 1e10 as i32);        // float to int saturates at i32::MAX
    println!("{}", f64::NAN as i32);    // NaN becomes 0
}
44
4294967295
3
2147483647
0

None of these lines gives an error or a warning. The traits From and TryFrom are the safe alternative: From exists only for conversions that always fit (u8 to u16, u32 to u64), and TryFrom returns a Result that is an error when the value does not fit:

fn main() {
    let byte: u8 = 0xB9;
    let wide = u16::from(byte);                 // always fits: From
    println!("{wide:04X}");

    println!("{:?}", u8::try_from(200_i32));    // fits
    println!("{:?}", u8::try_from(300_i32));    // does not fit: an error, not 44

    match u8::try_from(-1_i32) {
        Ok(level) => println!("level {level}"),
        Err(e) => println!("refused: {e}"),
    }
}
00B9
Ok(200)
Err(TryFromIntError(PosOverflow))
refused: number too small to fit in target type

So the fix for the mismatched types above is u64::from(kwh) * price_per_kwh. Text becomes a number with parse, which also returns a Result: "350".parse::<u32>() is Ok(350), and "35O" (with a letter O) is an error you must handle.

Floats and money

f32 and f64 are floating-point numbers with the usual binary rounding: most decimal fractions, like 0.1, cannot be stored exactly. Rust behaves exactly like C and Python here:

fn main() {
    println!("{}", 0.1 + 0.2);
    println!("{}", 0.1 + 0.2 == 0.3);
    println!("{:.2}", 2.675);                  // rounds the binary value, 2.67499999…
    println!("{}", (0.29_f64 * 100.0) as i32); // 28, not 29: as cuts off 28.999999…

    // Money as whole numbers: the price in ten-thousandths of a euro
    const PRICE_PER_KWH: u64 = 2_700;          // €0.2700 per kWh
    let kwh: u64 = 350;
    let cents = (kwh * PRICE_PER_KWH + 50) / 100;   // + 50: round to whole cents
    println!("€{}.{:02}", cents / 100, cents % 100);
}
0.30000000000000004
false
2.67
28
€94.50

For amounts of money, use integers: whole cents, or smaller units when prices have more decimals. Kestrel's fixed tariff of €0.2700 per kWh becomes 2,700 ten-thousandths of a euro, and 350 kWh costs exactly 945,000 ten-thousandths, rounded to 9,450 cents: €94.50. The u64 type leaves room for totals across all customers. The background is on rounding errors and the C version on floating point and money.

Formatting output

println!, print! and format! (which returns a String instead of printing) share one formatting language. Each {} takes the next argument, or names a variable directly; after a colon come options for width, alignment, number base and precision:

fn main() {
    let meter = "M-30004";
    let kwh = 412.338;
    let crc: u16 = 0xF1F4;
    let readings = [350, 412, 120];

    println!("{meter}: {kwh} kWh");               // a variable name inside the braces
    println!("{}: {:.1} kWh", meter, kwh);        // positional, one decimal
    println!("[{meter:>10}] [{meter:<10}]");      // right- and left-aligned in 10 characters
    println!("CRC {crc:04X}, {crc:#06x}, {crc}"); // hexadecimal and decimal
    println!("{readings:?}");                     // Debug format, for developers
    println!("{:?}", "Größe");                    // Debug shows the quotes
}
M-30004: 412.338 kWh
M-30004: 412.3 kWh
[   M-30004] [M-30004   ]
CRC F1F4, 0xf1f4, 61940
[350, 412, 120]
"Größe"
  • {} uses a type's Display form, meant for users. Numbers and text have one; arrays and lists do not.
  • {:?} uses the Debug form, meant for developers: arrays, tuples, Option values and quoted strings. Your own types get one with #[derive(Debug)] (see Structs, Enums and Methods).
  • Names inside the braces ({meter}) have worked since Rust 1.58 (January 2022); older code writes println!("{}", meter). Only plain variable names are allowed there, not expressions such as {a + b}.
  • The compiler checks the format string against the arguments: a missing or extra argument is a compile error, not a crash or garbage output as with C's printf.