What Is the Difference Between a Statement and an Expression in Rust?

Expressions in Rust return values and can be used anywhere, whereas statements perform actions and do not return values.

The value-killer semicolon

You write a function in Python. You calculate a discount. You return the result. You port the code to Rust. You write fn get_discount() -> f64 { let rate = 0.1; rate }. It compiles. You feel good. Then you add a debug print. fn get_discount() -> f64 { let rate = 0.1; println!("{rate}"); rate }. It still compiles. You move the print. fn get_discount() -> f64 { let rate = 0.1; println!("{rate}"); rate; }. The compiler screams. E0308. Mismatched types. You expected f64. You got ().

The culprit is the semicolon. In Rust, the semicolon is not just punctuation. It is a value-killer. It turns an expression into a statement. It consumes the value and returns nothing. Understanding the difference between statements and expressions is not a syntax quiz. It is the key to writing idiomatic Rust. It determines how you structure logic, how you return values, and how you avoid the most common compiler errors.

Expressions produce values. Statements perform actions.

An expression evaluates to a value. A statement performs an action and returns the unit type (). That is the definition. The mental model that matters is composition. You can nest expressions. You can pass expressions to functions. You can assign expressions to variables. You can return expressions. Statements break the chain. You cannot pass a statement. You cannot assign a statement. You can only execute a statement.

Think of an expression as a question that demands an answer. "What is 2 + 2?" The answer is 4. You can use 4 anywhere. A statement is a command. "Wash the dishes." You do it. You get no value back. In Rust, you get (). () is a value. It is the unit type. It contains no data. It represents "nothing meaningful". Statements return ().

Minimal example

fn main() {
    // Expression: the literal 5 evaluates to 5.
    // The let binding is a statement, but the right-hand side is an expression.
    let x = 5;

    // Expression: if block evaluates to a string slice.
    // Rust allows if to return a value because it is an expression.
    let label = if x > 0 { "positive" } else { "non-positive" };

    // Expression: block evaluates to the last expression without semicolon.
    // The semicolon after temp makes it a statement, returning ().
    // The block returns temp + 1.
    let y = {
        let temp = x * 2;
        temp + 1
    };

    // Expression: function call returns a value.
    let z = calculate(x);
}

fn calculate(n: i32) -> i32 {
    n * 10
}

The semicolon switch

The semicolon toggles between expression and statement. Add a semicolon to an expression, and it becomes a statement. The expression evaluates. The value discards. The result is (). Remove the semicolon, and the value bubbles up.

Look at let x = 5;. The 5 is an expression. The whole line is a statement. Statements return (). If you write let y = let x = 5;, the compiler rejects it. You cannot assign a statement to a variable. You can only assign expressions.

This rule applies to function bodies. A function body is a block expression. The value of the block is the return value. The last item in the block determines the value. If the last item is a statement, the block returns ().

// Function returns i32.
// The last expression is 42. No semicolon.
// The block evaluates to 42.
fn good() -> i32 {
    42
}

// Function returns i32.
// The last item is 42;.
// The semicolon makes it a statement.
// The statement returns ().
// The block returns ().
// Compiler rejects with E0308 (mismatched types).
fn bad() -> i32 {
    42;
}

A trailing semicolon turns a return value into a void. Check your function endings.

Realistic example: Config loading

Expressions shine when you need to compute a value based on conditions. In C or Java, you often declare a temporary variable and assign inside branches. Rust lets you skip the temporary.

fn get_port() -> u16 {
    // Expression: match returns the result of the matching arm.
    // Each arm is an expression.
    // The match expression evaluates to the type of the arms.
    match std::env::var("PORT") {
        Ok(val) => val.parse().unwrap_or(8080),
        Err(_) => 3000,
    }
}

fn get_timeout() -> u64 {
    // Expression: if-else returns a value.
    // Both arms must have the same type.
    let env_timeout = std::env::var("TIMEOUT");
    if env_timeout.is_ok() {
        env_timeout.unwrap().parse().unwrap_or(5000)
    } else {
        5000
    }
}

This pattern reduces boilerplate. You define the variable and compute the value in one step. The code reads like a calculation.

Why expression-oriented programming matters

Rust pushes you toward expression-oriented programming. You can nest expressions deeply. The compiler forces you to handle types at every level. This leads to code that is easier to refactor. You can extract an expression into a function. You can replace a block with a function call. Statements break this flow. You cannot extract a statement. You cannot replace a statement with a function call that returns a value.

Expression-oriented code is more declarative. You describe what the value is, not how to mutate state to get it. This aligns with Rust's ownership model. Values flow through expressions. Ownership transfers are explicit. Statements introduce bindings that can obscure ownership flow.

This design choice pays off in complex logic. Error handling becomes a chain of expressions. let result = do_a()?; let result = do_b(result)?; result;. Or better, do_a()?.do_b(). The ? operator is an expression. It returns a value or propagates an error. You can use it anywhere an expression is allowed.

Pitfalls and compiler errors

Trailing semicolon in return position. This is the most common error for beginners. You write a function. You add a debug print at the end. You forget to remove the semicolon from the return value.

fn process() -> String {
    let data = fetch_data();
    format!("Processed: {data}")
}

If you add println!("{data}"); after the format, the function returns (). The compiler rejects with E0308. Move the print before the return expression. Or use a block.

If without else. if is an expression. Every branch must produce the same type. If you omit the else, the implicit else branch returns ().

let x = if true { 1 };

The if arm returns 1. The implicit else returns (). Types do not match. The compiler rejects with E0308. Add an else arm that returns an i32. Or use if as a statement if you do not need a value.

Let inside expression. You cannot use let inside an expression context.

let y = let x = 5;

This is a syntax error. let is a statement. You cannot nest statements inside expressions. Use a block.

let y = {
    let x = 5;
    x
};

Convention aside: let _ = ... vs drop(...). When you have a value you want to discard, you have two options. let _ = value; binds the value to _. The value drops at the end of the scope. drop(value) calls the drop function immediately. Use drop when you need to free resources now. Use let _ when you want to silence a warning about an unused value. The community prefers let _ for discarding results of functions that return Result or Option when you do not care about the outcome. It signals intent.

Macros blur the line

Macros can look like statements. println!("hello"); looks like a statement. It is an expression. It returns (). You can write let _ = println!("hello");. It compiles. The macro expands to code that prints and returns ().

Some macros produce statements. macro_rules! can generate statements. When you call such a macro, you must use it in a statement context. You cannot assign the result. The macro documentation tells you whether it is expression-like or statement-like. Most standard library macros are expression-like. They return ().

Decision matrix

Use expressions whenever you need a value. Literals, function calls, operators, if, match, and blocks are all expressions.

Use let statements to bind names to values. You cannot nest let inside an expression.

Use blocks to create a scope or to return a computed value. The last expression without a semicolon becomes the block's value.

Use if and match as expressions to replace temporary variables. If every branch produces the same type, the whole construct produces that type.

Use let _ = value; to discard a value and silence warnings. The value drops at the end of the scope.

Use drop(value) to force an immediate drop. Use this when you need to release resources before the scope ends.

Treat the semicolon as a value-killer. If you want the value, leave it off.

Where to go next