Parameters and Return Values

Every function in Rust has a signature that says exactly what it takes in and what it hands back, and in Rust that signature is also an ownership contract. When you pass a value into a function, you are deciding whether the function borrows it temporarily or takes it over completely; when a function returns a value, it hands ownership to the caller. Understanding parameters and return values thoroughly is the fastest way to stop fighting the borrow checker and start using it as a safety net.

Overview: How Parameters and Return Values Work

In many languages, passing a variable into a function is a detail you barely think about: the language quietly decides whether to copy the value or share a pointer to it. Rust makes that decision explicit and checks it at compile time, and the type you choose for each parameter says exactly what happens to the value.

Think of a value like String as something you physically hold — a set of house keys. If a function parameter has type String, calling that function is like handing over your only set of keys: the function now owns the house, and you (the caller) no longer have access. This is called a move. If instead the parameter has type &String or, more idiomatically, &str, that’s like handing over a photocopy of the keys so the function can look at the house but you keep the originals — this is a borrow. A third option, &mut String, is like handing over the real keys with permission to rearrange the furniture and then give the keys back — a mutable borrow. Rust’s borrow checker enforces a strict rule around this: at any moment you can have either one mutable borrow or any number of immutable borrows of a value, never both, so no code can read data while another part of the program is changing it out from under it.

Small, fixed-size types like i32, f64, bool, and char behave differently: they implement the Copy trait, so passing one by value duplicates it instead of moving it. That’s why you can pass an i32 into a function and keep using the original variable afterward, but doing the same with a String or a Vec<T> (neither of which implement Copy) invalidates the original binding.

Return values work through Rust’s expression-oriented syntax. A function’s return type is declared after an arrow, as in -> i32, and the value produced by the last expression in the function body, written without a trailing semicolon, becomes the return value — no return keyword required, though return is available for exiting early. Add a semicolon to what you meant as the final expression and you turn it into a statement that produces the unit type () instead, which is one of the most common early stumbling blocks for new Rust programmers (covered below in Common Mistakes). If a function has no -> at all, it implicitly returns (), Rust’s empty tuple, used to mean “no meaningful value.”

Ownership flows through return values just as it does through parameters: when a function returns an owned type like String or Vec<T>, it transfers ownership to whatever variable receives it in the caller. This lets a function safely create data internally and hand it back out without anyone needing to manage memory by hand — when the returned value’s last owner eventually goes out of scope, Rust automatically drops it and frees its memory.

Syntax

The general shape of a function signature with parameters and a return type looks like this:

fn function_name(param1: Type1, param2: &Type2, param3: &mut Type3) -> ReturnType {
    // function body
    result_expression
}
  • fn function_name — the fn keyword followed by the function’s name, written in snake_case by convention.
  • (param1: Type1, ...) — a comma-separated parameter list; every parameter must have an explicit type annotation, since Rust never infers parameter types.
  • &Type2 / &mut Type3 — a parameter type prefixed with & is an immutable borrow, and &mut is a mutable borrow; a type with no & takes ownership (or copies, for Copy types).
  • -> ReturnType — declares what type the function returns; omit this entirely if the function returns nothing meaningful (implicitly ()).
  • the final line, result_expression, with no semicolon — its value becomes the function’s return value.
Parameter Style Example Effect on Caller When to Use
By value (owned) fn f(s: String) Ownership moves in; caller’s variable becomes invalid (unless the type is Copy) Function needs to own, store, or consume the data
By value (Copy type) fn f(n: i32) Value is duplicated; caller keeps using the original Small fixed-size types: integers, floats, bool, char
Immutable reference fn f(s: &str) Data is borrowed for reading only; caller keeps ownership Function only needs to read the data
Mutable reference fn f(s: &mut String) Data is borrowed for reading and writing; caller keeps ownership after the call Function needs to modify the caller’s data in place

Examples

Example 1: Returning a Value from an Expression

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let sum = add(5, 7);
    println!("The sum is {}", sum);
}

Output:

The sum is 12

Both a and b are i32, a Copy type, so 5 and 7 are simply copied into the function — there’s no ownership transfer to worry about. The body has a single expression, a + b, with no trailing semicolon, so its value becomes add‘s return value automatically, equivalent to writing return a + b; but more idiomatic.

Example 2: Ownership Moving Through a Function

fn describe(name: String) -> String {
    format!("Hello, {}! Welcome to Rust.", name)
}

fn main() {
    let name = String::from("Ferris");
    let greeting = describe(name);
    println!("{}", greeting);
}

Output:

Hello, Ferris! Welcome to Rust.

Here describe takes name: String by value, so calling describe(name) moves the String out of main‘s name variable and into the function’s parameter. From that line onward, main‘s name is no longer usable — the code above never tries to use it again, which is why it compiles. Inside describe, format! builds a brand-new String, and that expression, with no semicolon, is returned, transferring ownership of the new string to greeting back in main.

Example 3: Borrowing Instead of Moving, and Returning Multiple Values

fn analyze(text: &str) -> (usize, String) {
    let length = text.len();
    let upper = text.to_uppercase();
    (length, upper)
}

fn main() {
    let message = String::from("hello rust");
    let (len, upper) = analyze(&message);
    println!("Original: {}", message);
    println!("Length: {}", len);
    println!("Uppercase: {}", upper);
}

Output:

Original: hello rust
Length: 10
Uppercase: HELLO RUST

Passing &message creates an immutable borrow, so analyze only gets read access to the string data and message is still perfectly valid afterward — that’s why println!("Original: {}", message) works after the call. Because Rust functions can only return one value, analyze bundles the length and the uppercase copy into a tuple, (usize, String), and the caller immediately destructures it into len and upper with a pattern in the let.

Example 4: Mutating Through a Reference with No Return Value

fn add_exclamation(text: &mut String) {
    text.push('!');
}

fn main() {
    let mut message = String::from("Hello");
    add_exclamation(&mut message);
    println!("{}", message);
}

Output:

Hello!

add_exclamation has no -> at all, so it implicitly returns () — it isn’t meant to hand back a value, only to have a side effect. Its parameter is &mut String, a mutable borrow, so it can call .push('!') to modify the caller’s string in place. Note that message itself must be declared mut in main, and the call site must also write &mut message explicitly — Rust never lets you mutate through a reference unless every step of the way says so.

How It Works Step by Step

Walking through Example 2 line by line shows exactly what the compiler tracks:

  1. let name = String::from("Ferris"); allocates a String on the heap and makes name its owner.
  2. describe(name) passes name by value. Because String is not Copy, the compiler moves the string’s internal data (pointer, length, capacity) into describe‘s parameter, also called name. The compiler now marks main‘s name as moved-out; any later attempt to read it would be a compile-time error.
  3. Inside describe, format!("Hello, {}! Welcome to Rust.", name) borrows the parameter name to read its contents (through the Display trait) and allocates a completely new String holding the formatted text.
  4. That new String is the function’s final expression, so ownership of it transfers out of describe and into greeting back in main.
  5. At the closing brace of describe, its local parameter name goes out of scope. Since nothing moved it out again, Rust automatically runs its destructor and frees that heap memory — the original “Ferris” string is deallocated here, well before main ends.
  6. println!("{}", greeting) reads the new string; when main ends, greeting goes out of scope and its memory is freed too.

Notice there is no garbage collector anywhere in this trace — every allocation has exactly one owner at every point in time, and Rust inserts the deallocation automatically wherever that owner’s scope ends. This is what people mean when they say Rust manages memory at compile time.

Common Mistakes

Mistake 1: Using a Value After Moving It Into a Function

fn take_ownership(s: String) {
    println!("Took: {}", s);
}

fn main() {
    let text = String::from("hello");
    take_ownership(text);
    println!("{}", text);
}

This fails to compile because take_ownership(text) moves text‘s data into the function; by the time the next line runs, main‘s text no longer owns anything. The compiler rejects it with something like:

error[E0382]: borrow of moved value: `text`

The fix is to pass a reference instead, so main keeps ownership:

fn take_ownership(s: &str) {
    println!("Took: {}", s);
}

fn main() {
    let text = String::from("hello");
    take_ownership(&text);
    println!("{}", text);
}

Changing the parameter to &str and passing &text means the function only borrows the string, so text is still valid on the following line. If the function genuinely needs its own independent copy, text.clone() is the other option, at the cost of an extra heap allocation.

Mistake 2: An Accidental Semicolon Changes the Return Value

fn add(a: i32, b: i32) -> i32 {
    a + b;
}

Adding a semicolon after a + b turns it from an expression into a statement, and a statement produces no value. The function’s body then implicitly evaluates to (), which doesn’t match the declared return type i32:

error[E0308]: mismatched types
expected `i32`, found `()`

The fix is simply to drop the trailing semicolon so the expression’s value is returned:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

let result = add(3, 4);
println!("{}", result);

This single-character difference trips up almost everyone coming from a language where semicolons are just line terminators. In Rust, a trailing semicolon deliberately discards a value.

Mistake 3: Returning a Reference to Data the Function Just Created

fn make_string() -> &String {
    let s = String::from("temporary");
    &s
}

s is a local variable owned by make_string; it is dropped the moment the function ends. Returning &s would be a reference to memory that no longer exists, so the borrow checker refuses to compile this — in fact it won’t even get that far, because a bare &String return type needs a lifetime the compiler can’t infer here:

error[E0106]: missing lifetime specifier

The fix is to return the owned String itself, transferring ownership to the caller instead of trying to hand back a reference to something local:

fn make_string() -> String {
    let s = String::from("temporary");
    s
}

fn main() {
    let result = make_string();
    println!("{}", result);
}

As a rule of thumb: a function can return a reference only to data that outlives the call — typically something borrowed from one of its own parameters — never to a value it created on its own stack or heap frame.

Best Practices

  • Take &str instead of String (and &[T] instead of &Vec<T>) for parameters that only need to read data — it lets callers pass either owned or borrowed data without extra allocations.
  • Return owned types like String or Vec<T> when a function creates new data — don’t try to return a reference to something the function allocated itself.
  • Reach for &mut T parameters when a function’s whole job is to modify caller-owned data in place, rather than returning a new value and requiring the caller to reassign it.
  • Use tuples, or better, a small named struct, when a function logically needs to return more than one related value — a struct also documents what each value means.
  • Prefer letting the last expression be the return value over an explicit return statement, and reserve return for genuine early exits.
  • Only clone (.clone()) when you actually need an independent copy; reaching for it just to silence a move error usually means a borrow would have worked and been cheaper.

Practice Exercises

  • Write a function square(n: i32) -> i32 that returns n multiplied by itself, and call it from main with a few different numbers, printing each result.
  • Write a function first_word(s: &str) -> &str that returns just the first word of a sentence (hint: s.split_whitespace().next() gives you an Option<&str> you can handle with unwrap_or("")). Test it with "the quick brown fox" and confirm it prints the.
  • Write a function sum_and_average(numbers: Vec<i32>) -> (i32, f64) that takes ownership of a vector and returns both the sum and the average as a tuple. Call it once with vec![10, 20, 30, 40] and print both values (expected: sum 100, average 25).

Summary

  • Every parameter’s type encodes an ownership decision: a bare type like String moves (or copies, for Copy types), &T borrows immutably, and &mut T borrows mutably.
  • Passing a non-Copy value by value moves it — the caller’s original binding becomes unusable unless the value is returned back or cloned.
  • A function’s return type follows ->, and the last expression in the body, written without a semicolon, becomes the return value; adding a semicolon turns it into a ()-producing statement instead.
  • Return multiple values with a tuple (or a struct for clarity), since Rust functions can only return a single value.
  • Borrow with & or &mut whenever a function doesn’t need to own the data, to avoid unnecessary moves and allocations.
  • The borrow checker enforces “one mutable reference or many immutable references, never both” entirely at compile time, which is how Rust prevents data races and use-after-free without a garbage collector.