Functions

Returning Expressions

In Rust, every function body is a block expression { ... }. When the final expression in a function body omits a trailing semicolon, it becomes the block's tail expression, and its evaluated value is automatically returned to the caller without writing the return keyword.

Tail Expressions as Return Values

A function executes its statements sequentially from top to bottom and returns the value produced by its final expression:

fn rectangle_area(width: u32, height: u32) -> u32 {
    width * height
}

fn fahrenheit_to_celsius(f: f64) -> f64 {
    let shifted = f - 32.0;
    shifted * 5.0 / 9.0
}

fn main() {
    println!("Area:    {}", rectangle_area(8, 5));
    println!("Celsius: {:.1}", fahrenheit_to_celsius(98.6));
}
Area:    40
Celsius: 37.0

In fahrenheit_to_celsius, let shifted = f - 32.0; is a declaration statement terminated by a semicolon, and shifted * 5.0 / 9.0 is the tail expression whose f64 value becomes the function's return value.

fn fahrenheit_to_celsius(f: f64) -> f64 {
    let shifted = f - 32.0;   <-- Statement (ends with `;`)
    shifted * 5.0 / 9.0       <-- Tail expression (no `;`) -> Returned as f64
}

Tail Expression vs return Statement

Writing shifted * 5.0 / 9.0 as the tail expression and writing return shifted * 5.0 / 9.0; at the end of a function compile to identical machine code. However, idiomatic Rust uses bare tail expressions for the final return value of a function and reserves the return keyword for early exits from the middle of a function.

The Clippy linter enforces this convention via the needless_return lint:

Position in Function Idiomatic Syntax Why
Final expression of the function body expression (no return, no ;) Treats the function body naturally as a value-producing block
Guard clause or early exit inside an if/loop return expression; Explicitly signals non-linear control flow that skips the rest of the function

Returning Control-Flow and Block Expressions

Because if, match, loop, and nested { ... } blocks are expressions in Rust, any of them can serve directly as a function's tail expression.

Returning an if / else Expression

When an if / else expression is the final item in a function, the tail expression of whichever branch executes becomes the return value of the entire function:

fn clamp(value: i32, min: i32, max: i32) -> i32 {
    if value < min {
        min
    } else if value > max {
        max
    } else {
        value
    }
}

fn main() {
    println!("clamp(-5, 0, 10) = {}", clamp(-5, 0, 10));
    println!("clamp(4, 0, 10)  = {}", clamp(4, 0, 10));
    println!("clamp(25, 0, 10) = {}", clamp(25, 0, 10));
}
clamp(-5, 0, 10) = 0
clamp(4, 0, 10)  = 4
clamp(25, 0, 10) = 10

Notice that no semicolon appears inside any of the three branches (min, max, value), nor after the closing brace } of the else block.

Returning a match Expression

A match expression at the end of a function returns the value of the matched arm directly:

fn http_status_text(code: u16) -> &'static str {
    match code {
        200 => "OK",
        201 => "Created",
        400 => "Bad Request",
        404 => "Not Found",
        500..=599 => "Server Error",
        _ => "Unknown Status",
    }
}

fn main() {
    println!("200 -> {}", http_status_text(200));
    println!("503 -> {}", http_status_text(503));
}
200 -> OK
503 -> Server Error

Returning a Nested Block or loop Expression

A nested block { ... } or a loop that breaks with a value (break expr) can also be the function's tail expression:

fn next_power_of_two(target: u32) -> u32 {
    let mut power = 1;

    loop {
        if power >= target {
            break power; // Evaluates the `loop` expression to `power`
        }
        power *= 2;
    }
}

fn main() {
    println!("Next power of two >= 19: {}", next_power_of_two(19));
}
Next power of two >= 19: 32

Evaluation and Drop Order When Returning an Expression

When a function returns a tail expression, Rust follows a strict evaluation and cleanup order:

  1. The tail expression is evaluated first, and its resulting value is moved or copied into the caller's return slot.
  2. Any temporary values created while evaluating the tail expression are dropped.
  3. All remaining local variables and parameters owned by the function are dropped in reverse declaration order (LIFO) before control returns to the caller.

Because step 1 happens before step 3, a tail expression can compute a value derived from borrowing a local variable (such as local_string.len()), even though local_string is destroyed as the function exits:

fn count_non_empty_lines(text: &str) -> usize {
    let owned_copy = text.trim().to_string();

    // `owned_copy` is borrowed here to count lines, and the `usize` result is
    // copied into the return slot BEFORE `owned_copy` is dropped at function exit.
    owned_copy.lines().filter(|line| !line.is_empty()).count()
}

fn main() {
    let sample = "\nalpha\n\nbeta\ngamma\n";
    println!("Non-empty lines: {}", count_non_empty_lines(sample));
}
Non-empty lines: 3

Common Compiler Errors

Accidental Semicolon on the Tail Expression

Adding a semicolon to the final expression of a function converts it into an expression statement that discards its value and causes the function block to evaluate to ():

// ❌ does not compile
fn double(x: i32) -> i32 {
    x * 2;
}

fn main() {
    println!("{}", double(6));
}
error[E0308]: mismatched types
 --> src/main.rs:2:22
  |
2 | fn double(x: i32) -> i32 {
  |    ------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
3 |     x * 2;
  |          - help: remove this semicolon to return this value

error: aborting due to 1 previous error

Semicolon Inside One Branch of a Tail if Expression

When an if / else expression is used as a function's tail expression, every branch must end with a value-producing tail expression of the return type. Putting a semicolon inside even one branch turns that branch's value into ():

// ❌ does not compile
fn absolute(x: i32) -> i32 {
    if x >= 0 {
        x
    } else {
        -x;
    }
}

fn main() {
    println!("{}", absolute(-3));
}
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:6:9
  |
3 | /     if x >= 0 {
4 | |         x
  | |         - expected because of this
5 | |     } else {
6 | |         -x;
  | |         ^^^
  | |         |
  | |         expected `i32`, found `()`
  | |         help: consider removing this semicolon
7 | |     }
  | |_____- `if` and `else` have incompatible types

error: aborting due to 1 previous error

Returning a Reference to a Local Variable

While a function can return an owned value (String, Vec<T>, i32) computed in its tail expression (because ownership transfers to the caller), a tail expression cannot return a reference (&T) pointing to a local variable owned by the function, because that local variable is dropped when the function exits:

// ❌ does not compile
fn make_greeting(name: &str) -> &str {
    let formatted = format!("Hello, {name}!");
    &formatted
}

fn main() {
    println!("{}", make_greeting("Ferris"));
}
error[E0515]: cannot return reference to local variable `formatted`
 --> src/main.rs:4:5
  |
4 |     &formatted
  |     ^^^^^^^^^^ returns a reference to data owned by the current function

error: aborting due to 1 previous error

Return the owned String directly (formatted instead of &formatted with return type -> String) so ownership moves safely to the caller.