How to Write Functions in Rust

Define Rust functions using the fn keyword, specifying argument types and optional return types within curly braces.

The contract before the code

You're porting a Python script to Rust. You write a function to calculate a discount. In Python, you define the function, return the value, and move on. In Rust, you write the function, hit run, and the compiler complains about a missing type. You add the type. You add a semicolon at the end of your return line. The compiler complains again: expected f64, found (). You're confused. The logic is correct. The math is correct. But Rust has different rules for how functions return values and how they handle data.

Functions in Rust are the building blocks of your program, but they come with a strict contract system. You declare exactly what goes in and what comes out. The compiler enforces that contract before the code runs. This catches bugs like type mismatches and missing returns at compile time, not in production.

Functions as vending machines

Think of a function as a vending machine with a strict inventory list. You can't just shove anything into the slot. The machine expects exact coins, exact sizes. If the machine says it dispenses a soda, it must dispense a soda. It can't dispense a cookie, and it can't dispense nothing.

Rust functions work the same way. Every argument needs a type. Every return needs a type. The compiler checks the contract before the machine ever runs. If you try to pass a string where an integer is expected, the compiler rejects you. If you promise to return a number but return nothing, the compiler rejects you. This strictness feels tedious at first, but it eliminates entire classes of runtime errors. You spend time arguing with the compiler now so you don't spend time debugging null pointers and type errors later.

Minimal function syntax

A function starts with the fn keyword, followed by the name, parentheses for arguments, and braces for the body. Arguments have a name and a type separated by a colon. The return type follows an arrow ->.

/// Calculates the sum of two integers.
fn add(x: i32, y: i32) -> i32 {
    // The last expression is the return value.
    // No semicolon needed here.
    x + y
}

fn main() {
    let result = add(5, 10);
    println!("Result: {}", result);
}

The fn keyword declares the function. The name add follows. The parentheses hold the arguments x: i32 and y: i32. Each argument specifies a name and a type. The -> i32 specifies that the function returns an i32. The body is inside the braces.

The crucial detail is the return mechanism. Rust functions return the value of the last expression. An expression produces a value. A statement performs an action but produces no value. The line x + y is an expression that evaluates to the sum. If you add a semicolon, it becomes a statement. The function then returns (), the unit type, which is Rust's way of saying "nothing".

Drop the semicolon if you want a value back.

The semicolon trap

The semicolon trap is the most common mistake for beginners. You write a function that returns a value, but you accidentally add a semicolon to the last line. The compiler rejects the code with E0308 (mismatched types).

/// This function has a bug.
fn broken_add(x: i32, y: i32) -> i32 {
    // The semicolon turns this into a statement.
    // The function returns () instead of i32.
    x + y;
}

The compiler sees the return type i32 but finds the body returns (). It flags the mismatch. The fix is to remove the semicolon. This rule applies to the last expression in any block, including if expressions and match expressions.

Use the return keyword only for early exits. The idiomatic style is to let the last expression flow back as the return value. This keeps the code clean and avoids redundant syntax.

Trust the last expression. It's the return value.

Arguments: copy, move, or borrow

Rust functions handle arguments differently depending on the type. Some types are copied. Some are moved. Some are borrowed. Understanding this distinction is essential for writing efficient functions.

Primitive types like i32, f64, and bool implement the Copy trait. When you pass a Copy type to a function, the value is copied. The original variable remains usable.

fn double(x: i32) -> i32 {
    x * 2
}

fn main() {
    let num = 5;
    let result = double(num);
    // num is still usable because i32 is Copy.
    println!("Original: {}", num);
}

Types like String and Vec do not implement Copy. When you pass them to a function, ownership moves into the function. The original variable is invalidated.

/// Takes ownership of the string.
fn consume_string(s: String) {
    println!("Consumed: {}", s);
}

fn main() {
    let text = String::from("hello");
    consume_string(text);
    // This line causes E0382 (use of moved value).
    // println!("{}", text);
}

If you try to use text after passing it to consume_string, the compiler rejects you with E0382. The ownership moved. To avoid moving ownership, pass a reference instead.

/// Borrows the string without taking ownership.
fn read_string(s: &str) {
    println!("Read: {}", s);
}

fn main() {
    let text = String::from("hello");
    read_string(&text);
    // text is still usable.
    println!("Original: {}", text);
}

The function read_string takes &str, a string slice. This borrows the data without taking ownership. The original String remains valid. This is the preferred pattern for functions that only need to read data.

Convention aside: prefer &str over &String for function arguments. &str is more flexible. It accepts both &String and &"literal". &String restricts the caller to pass only String values. The community calls this "taking the most general type."

Borrow when you don't need ownership.

Mutability and scope

Functions can mutate their arguments if the arguments are declared mutable. The mut keyword applies to the binding inside the function, not the original variable.

/// Increments the value in place.
fn increment(mut x: i32) {
    x += 1;
    println!("Incremented: {}", x);
}

fn main() {
    let num = 5;
    // num is immutable, but the copy inside increment is mutable.
    increment(num);
    println!("Original: {}", num);
}

The mut keyword on x allows the function to modify the local copy. The original num remains immutable. If you need to mutate the original data, pass a mutable reference.

/// Increments the value via mutable reference.
fn increment_ref(x: &mut i32) {
    *x += 1;
}

fn main() {
    let mut num = 5;
    increment_ref(&mut num);
    println!("Updated: {}", num);
}

The function takes &mut i32, a mutable reference. The caller must provide a mutable binding. The function dereferences x with *x to modify the value. This pattern is common for functions that update data in place.

Write the signature first. Let the compiler drive the implementation.

Realistic example: parsing and validation

Real-world functions often involve parsing input, validating data, and returning results. Rust's Result type is the standard way to handle errors.

/// Parses a command string and returns an action code.
/// Returns an error if the command is unknown.
fn parse_command(input: &str) -> Result<i32, &'static str> {
    match input.trim() {
        "start" => Ok(1),
        "stop" => Ok(2),
        "restart" => Ok(3),
        // Unknown commands return an error string.
        _ => Err("Unknown command"),
    }
}

fn main() {
    let cmd = "  start  ";
    match parse_command(cmd) {
        Ok(code) => println!("Action code: {}", code),
        Err(e) => println!("Error: {}", e),
    }
}

The function takes &str and returns Result<i32, &'static str>. The Result type indicates success or failure. The match expression handles different cases. Valid commands return Ok(code). Unknown commands return Err("Unknown command"). The caller uses match to handle both cases.

This pattern is idiomatic Rust. Functions return Result when failure is possible. The caller must handle the error explicitly. This prevents silent failures and forces error handling.

Treat the return type as a contract. If failure is possible, return Result.

Pitfalls and compiler errors

Functions in Rust are strict. The compiler catches many errors early. Here are common pitfalls.

If you return a value of the wrong type, the compiler rejects you with E0308 (mismatched types). For example, returning a String when the signature says &str.

fn get_greeting() -> &str {
    // E0308: mismatched types.
    // Returns String, expected &str.
    String::from("Hello")
}

The fix is to return a string literal or adjust the return type.

If you use a variable after moving it, the compiler rejects you with E0382 (use of moved value). This happens when you pass a non-Copy type to a function and try to use it afterward.

fn take_string(s: String) {}

fn main() {
    let text = String::from("hello");
    take_string(text);
    // E0382: use of moved value `text`.
    println!("{}", text);
}

The fix is to borrow the value with & or clone it.

If you forget the return type annotation, the compiler rejects the code. Rust requires return type annotations on all functions. Python allows omitting it; Rust does not. This makes the contract explicit.

// Error: missing return type.
fn add(x: i32, y: i32) {
    x + y
}

The fix is to add -> i32.

Write the return type first. It guides the implementation and documents the function.

Decision: when to use functions vs alternatives

Rust offers several ways to define reusable logic. Choose the right tool based on the context.

Use fn for named functions that live at module level. Use closures for short, inline logic passed to iterators or callbacks. Use methods inside impl blocks when the function operates on a specific data structure. Use associated functions (methods with &self or self) when the logic is tied to the data it manipulates. Use macros when you need code generation or metaprogramming.

Reach for fn for most cases. It's the standard way to define functions. Use closures when you need a local function that captures variables from the surrounding scope. Use methods when the function is logically part of a type. Use macros sparingly, only when you need to generate code at compile time.

Counter-intuitive but true: the more you use macros, the harder the rest of your code becomes to reason about. Stick to fn and methods unless you have a compelling reason to use macros.

Where to go next