The semicolon is a switch, not punctuation
You're porting a function from Python to Rust. The logic is identical. You copy the calculation, paste it into a Rust function, and hit run. The compiler throws a fit about () versus i32. You scan the code. The math is correct. The types look right. The only difference is a tiny mark at the end of the last line.
In Python, that mark is optional whitespace. In Rust, that mark changes the meaning of the entire expression. It's not just ending a line. It's telling the compiler whether to keep the result or throw it away.
Expressions versus statements
Rust is an expression-oriented language. Almost everything you write is an expression. An expression evaluates to a value. 5 is an expression. x + y is an expression. A function call is an expression. Even a block {} is an expression.
A statement performs an action but does not produce a value. let x = 5; is a statement. It binds a name to a value, but the statement itself returns nothing.
The semicolon is the switch between these two modes. When you place a semicolon after an expression, you turn it into a statement. You suppress the value. The expression still runs, but the result is discarded.
Think of the semicolon as a trash can. When you write 5;, you evaluate 5, get the value 5, and immediately toss it into the trash. The compiler sees (), the unit type, which represents "no value." When you write 5 without a semicolon, the value 5 bubbles up to the caller.
fn main() {
let x = 5; // Semicolon terminates the let binding.
// The let statement returns (), which is discarded.
let y = {
let z = 10; // Statement. Returns ().
z + 1 // Expression. Returns 11.
};
// y is 11 because the block returns the value of the last expression.
}
Treat the semicolon as a trash can. If you don't want the value, toss it.
The unit type: what you're actually returning
When you discard a value with a semicolon, the expression doesn't vanish. It evaluates to (). This is the unit type. It's a type with exactly one value. It carries no information. It takes no memory.
Functions that don't return a value actually return (). You can write the return type explicitly, or you can omit it. Rust treats the omission as shorthand for -> ().
fn log_message(msg: &str) -> () {
println!("{msg}"); // println! returns (), which is discarded by the semicolon.
}
// Equivalent to:
fn log_message(msg: &str) {
println!("{msg}");
}
The semicolon after println! is required here because println! is a macro that expands to an expression returning (). The semicolon turns that expression into a statement. If you omit the semicolon, the function returns (), which matches the signature, but the style is unusual. The community convention is to terminate side-effecting calls with semicolons to signal that the result is intentionally ignored.
Trust the compiler on types. If it says you returned (), you threw away your result.
Control flow and the semicolon
Control flow keywords like return, break, and continue are statements. They transfer execution and must be terminated with a semicolon. This holds even when they carry a value.
break is a special case. It can inject a value into a loop block. The loop block is an expression, and its value is the value passed to break. The break statement itself still needs a semicolon.
fn find_first_even(numbers: &[i32]) -> Option<i32> {
let result = loop {
for n in numbers {
if n % 2 == 0 {
break Some(*n); // Semicolon terminates break. Value goes to loop.
}
}
break None; // Semicolon terminates break. Value goes to loop.
};
result
}
The semicolon ends the break statement. The value Some(*n) is passed out of the loop. The loop expression evaluates to that value. The assignment let result = ... captures it.
return always requires a semicolon. return value; exits the function immediately. The value is returned to the caller. The semicolon is mandatory.
continue requires a semicolon. It skips to the next iteration. No value is produced.
Use a semicolon after return, break, and continue to terminate the control flow statement. Omit the semicolon only when you're relying on the implicit return of a block or function.
Realistic example: returning a struct
You're writing a function that constructs a configuration object. The function performs several steps and returns the result.
struct Config {
debug: bool,
port: u16,
}
fn build_config(debug: bool) -> Config {
let port = if debug { 8080 } else { 80 };
// if expression returns u16. No semicolon needed inside the if.
Config { debug, port }
// Struct literal is an expression. No semicolon returns the Config.
}
If you add a semicolon after Config { debug, port }, the function returns (). The compiler rejects this with E0308 (mismatched types), reporting that it expected a Config but found ().
The if expression inside the function demonstrates another rule. if is an expression. It evaluates to the value of the chosen arm. You can assign it to a variable. You can return it. You can pass it to a function. The arms of the if must return the same type.
fn get_port(debug: bool) -> u16 {
if debug {
8080
} else {
80
}
}
Both arms return u16. The if expression returns u16. The function returns u16. No semicolons after the numbers. No return keyword. The code reads like a mathematical definition.
Don't fight the implicit return. It makes your code cleaner and reduces boilerplate.
Pitfalls and compiler errors
The most common mistake is adding a semicolon where you meant to return a value. This happens when you're used to languages where every line ends with a semicolon.
fn square(x: i32) -> i32 {
x * x; // Semicolon discards the result. Returns ().
}
The compiler rejects this with E0308 (mismatched types). The error message points to the function signature and the body. It tells you the function returns () but the signature promises i32. The fix is to remove the semicolon.
Another pitfall is forgetting a semicolon after a let binding. let is a statement. It requires a semicolon.
fn main() {
let x = 5 // Missing semicolon.
println!("{x}");
}
The compiler rejects this with a syntax error. It expects a semicolon after the binding. The error message is usually clear: "expected ;".
A subtler pitfall involves match expressions. match is an expression. Each arm evaluates to a value. If you add a semicolon after the arm value, you discard it. The arm returns ().
fn describe_number(n: i32) -> &'static str {
match n {
0 => "zero",
1 => "one",
_ => "other",
}
}
If you write 0 => "zero";, the arm returns (). The match expression returns (). The function returns (). The compiler rejects this with E0308.
Convention aside: the community prefers implicit returns for the last expression in a function. It reads like math. fn area(r: f64) -> f64 { r * r * std::f64::consts::PI }. No return keyword needed. No semicolon. Just the value.
Decision: when to use the semicolon
Use a semicolon after let bindings to declare variables without returning the binding itself.
Use a semicolon after function calls when you don't need the return value.
Use a semicolon after return, break, and continue to terminate the control flow statement.
Use a semicolon after macro invocations that perform side effects, like println!, panic!, or assert!.
Omit the semicolon after the final expression in a block or function when you want that value to be returned.
Omit the semicolon inside match arms when the arm evaluates to a value that contributes to the match result.
Omit the semicolon after if expressions when you're assigning the result or returning it.