Skip to lesson

learningrust.org / basics / 02-variables · lesson 2 of 26

TL;DR

Master Rust variables, mutability, and data types including integers, floats, booleans, characters, and type annotations

Key concepts

  • Rust variables
  • Rust data types
  • Rust mutability
  • Rust type annotations
  • Rust primitives

Variables and Data Types

TL;DR: Rust variables are immutable by default — use mut to allow reassignment. Rust is statically typed but infers types automatically. Scalar types include integers (i32, u64), floats (f64), booleans, and characters. All type conversions must be explicit; Rust never coerces types silently. Estimated: 20 minutes.

In this lesson, we'll explore how Rust handles variables and its basic data types. You'll learn about variable declaration, mutability, and the most common data types you'll use in your Rust programs.

What You'll Learn

By the end of this lesson you will write a small conversion toolkit: functions that widen a whole number into a decimal one, divide without losing the remainder, and turn a piece of text into a number. The part that catches people is that Rust's conversions are explicit but not safe — writing as forces you to admit a conversion is happening, and then performs it without checking that the value survived. A cast that silently turns 300 into 44 compiles, runs, and warns you about nothing.

The capstone's taskwork reads every one of its tasks as text off a disk file and has to turn priorities and counts into numbers before it can do anything with them, so the parse-and-convert step you practise here is the seam where its data enters the program. You arrive able to bind a value, reassign a mutable one, and read a function's return type (What Is Rust); what is new is that values now have types you must name and convert between deliberately.

Variables and Mutability

In Rust, variables are immutable by default - a key feature that promotes safer code:

fn main() {
    // No mut, because x is never reassigned. This is the default because it
    // is the common case — most bindings are set once and only read after.
    let x = 5;
    println!("The value of x is: {}", x);
    
    // This would cause a compile error:
    // x = 6; // Cannot assign twice to immutable variable
    
    // mut is written here ONLY because y is reassigned below. The keyword
    // marks the binding at the point of DECLARATION, so a reader can tell from
    // the first line whether a value is going to move under them later.
    let mut y = 5;
    println!("The value of y is: {}", y);
    y = 6; // This works!
    println!("The value of y is now: {}", y);
}

Basic Data Types

Rust is a statically typed language, which means it must know the types of all variables at compile time. The compiler can usually infer the type based on how we use the variable.

Scalar Types

Integers

This fence is deliberately unannotated — the four lines use two different ways of pinning a type, and naming the difference yourself is worth more than reading it. Before you scroll past it, answer two questions: what distinguishes the first pair of declarations from the second pair, and what would let a = 42; be, with no annotation and no suffix at all?

fn main() {
    let a: i32 = 42;
    let b: u32 = 42;

    let c = 42_i64;
    let d = 42_u64;
    
    println!("Different integer types: {}, {}, {}, {}", a, b, c, d);
}

Here are the two answers. The difference is where the type is written, not what it means: a and b are annotated after the name, which is what you reach for when you want a type other than the one inference would pick or when the type is part of the documentation; c and d carry the type as a suffix on the literal itself, which is the same choice made in one place instead of two and is the better form when the literal is the reason for the type. And let a = 42; with nothing at all gets i32 — the default the compiler falls back on when nothing else forces its hand.

One more thing the fence does not say out loud. The reason to choose u32 over i32 is never that the number happens to be small; it is that a negative value would be meaningless for what you are storing, and you would rather the compiler reject one at build time than have one appear at run time. u32 is a claim about the data, not an optimization.

Floating-Point Numbers

fn main() {
    // f64 is the default because on modern hardware it is not meaningfully
    // slower than f32, and the extra precision prevents more bugs than it costs.
    let x = 2.0;      // f64 (default)
    let y: f32 = 3.0; // f32 — worth asking for only when you have many of them
    
    println!("Floating point numbers: {}, {}", x, y);
}

Boolean

fn main() {
    // A bool is its own type, not a number in disguise. Rust will not let 1
    // stand in for true, which is why an if condition must BE a bool.
    let t = true;
    let f: bool = false;
    
    println!("Boolean values: {}, {}", t, f);
}

Character

fn main() {
    // Single quotes mean char, double quotes mean a string. A char holds one
    // Unicode scalar value in 4 bytes, whatever it is — which is why a char
    // is not a byte, and why 'z' and the cat are both single chars.
    let c = 'z';
    let z: char = 'ℤ';
    let heart_eyed_cat = '😻';
    
    println!("Characters: {}, {}, {}", c, z, heart_eyed_cat);
}

Type Conversion

Rust requires explicit type conversion in most cases:

One line below is a forward reference, and it is worth naming rather than skimming. str_num.parse::<i32>() does not hand back a number — it hands back a Result, a value that is either the parsed number or a description of why the text was not one, because "twelve" has to go somewhere. .unwrap() is the blunt way of getting the number out of it: it gives you the number when there is one and crashes the program when there is not. That is fine for a demonstration and wrong in a real program. Option and Result is where Result is taught properly, and Error Handling is where you learn the alternatives to .unwrap(). Until then, read .parse::<i32>().unwrap() as one phrase meaning "turn this text into an i32, and give up loudly if it is not one".

fn main() {
    let x = 42_i32;
    
    // Convert i32 to f64
    let y = x as f64;
    
    // String to number conversion
    let str_num = "42";
    let parsed_num = str_num.parse::<i32>().unwrap();
    
    println!("Original: {}", x);
    println!("Converted to float: {}", y);
    println!("Parsed from string: {}", parsed_num);
    
    // Try changing these values and conversions!
}

"Explicit" and "safe" are not the same promise. as is explicit — you have to write it — but it is also a truncating cast: it takes whatever bits fit in the destination and discards the rest, without a warning and without a runtime error. Work out what these three casts produce before you read on.

Predict

Every cast here is written with as, so every one of them compiles. Predict the three printed values before running.

fn main() {
  let big: i32 = 300;
  let as_byte = big as u8; // A

  let f = 3.9_f64;
  let as_int = f as i32; // B

  let neg: i32 = -1;
  let as_unsigned = neg as u8; // C

  println!("{} {} {}", as_byte, as_int, as_unsigned);
}
Continue learning

The answer is 44 3 255. Casting 300 to a u8 keeps only the low eight bits, giving 44; casting -1 to a u8 reinterprets its bit pattern as 255; and casting 3.9 to an i32 chops toward zero to 3 rather than rounding. as is explicit but unchecked — it never warns and never panics. When a value might not fit, reach for .round() before a float cast, or u8::try_from(x) when you want a conversion that can tell you it failed.

A Cast That Costs You the Answer

That warning is worth cashing in on a program where nothing announces the problem. The one below compiles without a warning, runs to completion, exits zero, and reports the wrong maximum.

Debug

This scans four sensor readings and reports the largest. The readings are 120, 200, 260 and 90, so the answer should be 260 — but it reports 200, and it does so without a single warning or panic. Commit a hypothesis about what happened to the third reading before you change anything, then fix it so the program reports 260.

fn main() {
  let readings: [i32; 4] = [120, 200, 260, 90];

  let mut highest: u8 = 0;
  for reading in readings {
      let stored = reading as u8;
      if stored > highest {
          highest = stored;
      }
  }

  println!("highest reading stored: {}", highest);
}

Expected output: highest reading stored: 260

Continue learning

Reading What the Compiler and the Runtime Tell You

Two of the failures in this lesson announce themselves, and they announce themselves in different formats. Learning to read each one is a separate skill from learning the rule it reports.

A compile error is laid out in labelled parts, and each part has a job. The --> line says where, as a file, line and column. The ^^^ carets underneath mark the exact span the compiler is objecting to — often narrower than the line, and the narrowness is information. A note: states a constraint the compiler is working from, and a help: offers a suggestion. The error[E0384] code at the front is a stable identifier you can look up with rustc --explain E0384, and it does not change between compiler versions the way the wording sometimes does. Treat help: as a suggestion and not an instruction: rustc is very good at proposing a change that makes the error go away, and the change that silences an error is not always the change that fixes your program — the .clone() it will offer you in a later lesson is the classic example.

A runtime panic is a different animal and gives you much less. When the .unwrap() in the Type Conversion example above meets text that is not a number, it prints called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit } and stops. Read it in three pieces: Result::unwrap() names the operation that gave up, ParseIntError names who was unhappy, and kind: InvalidDigit is the specific reason — some character in the text was not a digit. There is no --> and no ^^^ here, only a file and line number for the panic site, and nothing at all about why the input was wrong. That is why the message tells you run with RUST_BACKTRACE=1: setting that environment variable adds the chain of calls that led to the panic, which is usually the fastest way to find which of several .unwrap() calls was the one that blew up.

Practice Exercises

Try these exercises in the playground above:

  1. Create variables of different numeric types and perform some basic arithmetic
  2. Try converting between different numeric types using the as keyword
  3. Create a string from a number using the to_string() method
  4. Parse a string containing a floating-point number into an f64

Remember: Rust's type system is one of its strongest features, helping catch potential errors at compile time rather than runtime! Once you're comfortable with types, the next big concept is ownership — Rust's most distinctive feature.

Check Yourself

Before the build task, one question reaching back to the previous lesson. Answer from memory rather than by scrolling.

Recall

Without scrolling up: in What Is Rust you met a counter declared with let mut and incremented, and a function add whose body was the single line a + b with no semicolon and no return keyword. Which statement gets both of those right?

Continue learning

Try It Yourself

Reading about conversions is not the same as reaching for the right one. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one widens an integer to a float, one divides in floating point, one parses a string into a number. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.

The checks are assert_eq! calls inside main. A failing assert_eq! panics and prints both values it compared, so the first failure tells you exactly which function is still a stub and what it should have returned. The three functions reuse only what this lesson taught: the as cast, the fact that operand types decide which arithmetic runs, and .parse::<T>() with .unwrap().

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide which conversion that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// TODO 1: return value widened to an f64, so a later division keeps its
//   fractional part. to_float(7) -> 7.0
fn to_float(value: i32) -> f64 {
  let _ = value;
  0.0
}

// TODO 2: return the average of total over count as an f64 whose
//   fractional part survives. Convert BEFORE dividing.
//   average_of(250, 4) -> 62.5, average_of(7, 2) -> 3.5
fn average_of(total: i32, count: i32) -> f64 {
  let _ = total;
  let _ = count;
  0.0
}

// TODO 3: parse text into an i32. The caller guarantees it holds a valid
//   number, so .unwrap() is acceptable here.
//   parse_score("42") -> 42, parse_score("-7") -> -7
fn parse_score(text: &str) -> i32 {
  let _ = text;
  0
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  assert_eq!(to_float(7), 7.0, "to_float(7) should be the f64 7.0");
  assert_eq!(average_of(250, 4), 62.5, "average_of(250, 4) should be exactly 62.5, not 62.0");
  assert_eq!(average_of(7, 2), 3.5, "average_of(7, 2) should be exactly 3.5 - the remainder must survive");
  assert_eq!(parse_score("42"), 42, "parse_score('42') should be the number 42");
  assert_eq!(parse_score("-7"), -7, "parse_score('-7') should be the number -7");

  println!("All checks passed.");
  println!("Average of 250 over 4: {}", average_of(250, 4));
  println!("Parsed score: {}", parse_score("42"));
}

Expected output: All checks passed. Average of 250 over 4: 62.5 Parsed score: 42

Continue learning

Once it passes, try two variations and predict each before running:

  1. Convert after dividing instead of before. In average_of, change the body to to_float(total / count) — a single conversion applied to the integer result. Predict which check fails before running. The average_of(250, 4) check fails with left: 62.0, right: 62.5: 250 / 4 runs as integer division, throws the remainder away, and then widens 62 to 62.0. Where the conversion sits relative to the division is the whole difference.
  2. Feed parse_score something that is not a number. Change the final log line to parse_score("42 ") — the same digits with a trailing space. Predict whether it prints 42 or fails before running. It panics: called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }. parse does not trim, and .unwrap() turns that Err into a crash. This does not break a check — it replaces the last echoed line with a panic, which is the honest preview of why Result gets a whole lesson of its own.

Assembling a Conversion Chain

The build above let you write each conversion in isolation. These five lines are one chain, where the output of each step is the input to the next.

Arrange the code

These five lines take a piece of text, turn it into a number, widen it so a division keeps its fraction, divide, and print both the original number and the result. They have been shuffled. Put them in the order that compiles — then explain what forces that order: which name does each line introduce, and why can the widening step not be moved above the parsing step even though both are conversions?

  1. let halved = widened / 4.0;
  2. let raw = "250";
  3. println!("{} -> {}", parsed, halved);
  4. let parsed = raw.parse::<i32>().unwrap();
  5. let widened = parsed as f64;
Continue learning

Carrying the Cast Somewhere New

Transfer

Rust's as performs a real conversion at run time: 300 as u8 produces the value 44 because the bits are actually narrowed. TypeScript has an operator spelled the same way — value as number — and it is one of the most common sources of confusion for people arriving in Rust from TypeScript. Which statement names the genuine relationship between the two, rather than the surface resemblance?

Continue learning

Key Takeaways

  • Variables in Rust are immutable by default — use mut when you need to change a value
  • Rust is statically typed: the compiler must know every variable's type at compile time
  • Scalar types include integers (i32, u64), floats (f64), booleans, and characters
  • Type conversion requires explicit casting with as — Rust never converts types implicitly
  • Use .parse::<T>() to convert strings to numbers — it returns a Result, and .unwrap() claims the parse cannot fail (panicking if it does) while ? hands the failure to the caller

Next Steps

You now know how Rust stores and represents data — from integers and floats to booleans, characters, and type conversions. But data alone isn't enough; you need to make decisions and repeat actions based on that data. In the next lesson, we'll cover control flow — if expressions, loop/while/for loops, and an early look at match. You'll see how Rust's control flow constructs are expressions that return values, which leads to more concise and idiomatic code.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.