Functions were the very first thing I learned in Rust, and honestly, they were also the first place I got humbled by the compiler. Coming from languages that let you be loose about types and returns, Rust’s insistence on explicit function signatures felt strict at first. But the more I worked with it, the more I appreciated that strictness — it means a function’s signature tells you almost everything you need to know about what it does, without reading a single line of its body. In this article, I want to walk through how functions work in Rust: how to define them, how to call them, how parameters and return values behave, and how all of this connects to ownership and memory safety.
The Basic Anatomy of a Function
Every Rust program starts with a function called main:
fn main() {
println!("Hello, world!");
}
Let’s break this down. The fn keyword declares a function. main is the function’s name. The empty parentheses () mean it takes no parameters. The curly braces {} contain the function body. There’s no return type specified, which in Rust means the function implicitly returns the unit type () — essentially “nothing of interest.”
Rust uses snake_case for function names by convention — calculate_total, not calculateTotal or CalculateTotal. The compiler will actually emit a warning if you deviate from this, which I found surprising the first time it happened, but I’ve come to appreciate that Rust bakes style conventions right into the tooling.
Defining and Calling a Simple Function
Here’s a function with no parameters and no return value, called from main:
fn main() {
println!("Hello from main!");
greet();
}
fn greet() {
println!("Hello from a separate function!");
}
Output:
Hello from main!
Hello from a separate function!
One thing that surprised me when I started: unlike some languages, Rust doesn’t care whether a function is defined before or after it’s called, as long as it’s in scope. I defined greet after main here, and it still compiles and runs fine, because Rust doesn’t require forward declarations for top-level items.
Function Parameters
Parameters let you pass data into a function. In Rust, every parameter must have an explicit type annotation — there’s no type inference for function signatures, by design, because it makes function contracts unambiguous to both the compiler and to anyone reading the code.
fn main() {
print_measurement(5, 'h');
}
fn print_measurement(value: i32, unit_label: char) {
println!("The measurement is: {value}{unit_label}");
}
Output:
The measurement is: 5h
Here, value: i32 and unit_label: char are the parameters, each with its type explicitly declared. When calling print_measurement(5, 'h'), the arguments must match both the number and the types of the declared parameters, or the compiler will reject the call with a type mismatch error.
Function Bodies: Statements vs. Expressions
This is one of the most important concepts to internalize in Rust, and it directly affects how return values work. Rust function bodies are made up of a series of statements, optionally ending in an expression.
- A statement performs an action and does not return a value.
let y = 6;is a statement. - An expression evaluates to a value.
5 + 6,x + 1, and even a block{ ... }are expressions.
Here’s where it gets interesting — this code will NOT compile:
fn main() {
let x = (let y = 6);
}
This fails because let y = 6 is a statement, and statements don’t return values, so there’s nothing for x to bind to. This is actually different from C or C++, where assignment is an expression that returns the assigned value. Understanding this distinction early saves you a lot of confusion later, especially around function return values.
Return Values
A Rust function’s return type is declared after an arrow (->). The final expression in the function body — if it doesn’t have a trailing semicolon — becomes the return value.
fn main() {
let x = five();
println!("The value of x is: {x}");
}
fn five() -> i32 {
5
}
Output:
The value of x is: 5
Notice 5 has no semicolon. If I add a semicolon, 5; becomes a statement instead of an expression, and the function would no longer return 5 — it would try to return (), causing a type mismatch error against the declared -> i32 return type. This trips up nearly every Rust beginner at least once, myself included. The compiler error is actually quite helpful here:
error[E0308]: mismatched types
--> src/main.rs:7:16
|
7 | fn five() -> i32 {
| ---- ^^^ expected `i32`, found `()`
Here’s a slightly more elaborate example combining parameters and a return value:
fn main() {
let x = plus_one(5);
println!("x = {x}");
}
fn plus_one(x: i32) -> i32 {
x + 1
}
Output:
x = 6
You can also use an explicit return keyword to return early from a function, which is useful in conditional logic:
fn classify_number(n: i32) -> &'static str {
if n < 0 {
return "negative";
}
if n == 0 {
return "zero";
}
"positive"
}
fn main() {
println!("{}", classify_number(-5));
println!("{}", classify_number(0));
println!("{}", classify_number(42));
}
Output:
negative
zero
positive
Function Signatures as Documentation
I want to emphasize something I’ve come to value a lot: a Rust function signature is essentially a contract. Look at:
fn calculate_area(width: f64, height: f64) -> f64 {
width * height
}
Just from reading fn calculate_area(width: f64, height: f64) -> f64, I know exactly what types go in, what type comes out, and — crucially, because of ownership rules — whether the function takes ownership of its inputs or just borrows them. Compare that to:
fn print_and_return(s: String) -> String {
println!("{s}");
s
}
versus
fn print_only(s: &String) {
println!("{s}");
}
The first function’s signature, fn print_and_return(s: String) -> String, tells me it takes ownership of the String and gives an equivalent one back to the caller (a common pattern for functions that want to use a value and then hand it back). The second, fn print_only(s: &String), tells me it only borrows the string temporarily and the caller keeps ownership throughout. This is something you simply cannot tell at a glance in many other languages, but in Rust it’s right there in the signature.
Ownership, Parameters, and Memory Safety
Passing values into functions interacts directly with Rust’s ownership system. If a parameter’s type doesn’t implement Copy (like String or Vec<T>), passing it by value moves ownership into the function:
fn takes_ownership(some_string: String) {
println!("{some_string}");
} // some_string goes out of scope here and is dropped
fn main() {
let s = String::from("hello");
takes_ownership(s);
// println!("{s}"); // This would fail to compile — s was moved!
}
If I uncomment that last line, I get:
error[E0382]: borrow of moved value: `s`
This is Rust’s memory safety model in action: once s is moved into takes_ownership, main no longer owns it, and the compiler statically prevents me from using it again, eliminating a whole category of bugs like use-after-free or double-free that plague manually memory-managed languages.
If I want to keep using s in main after calling a function with it, I pass a reference instead — this is called borrowing:
fn calculate_length(s: &String) -> usize {
s.len()
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{s1}' is {len}.");
}
Output:
The length of 'hello' is 5.
Here, &s1 creates a reference to s1 without transferring ownership, so s1 is still valid and usable in main after the call. This is the standard way to avoid unnecessary moves and clones in Rust, and it’s central to writing idiomatic, efficient code.
Practical, Real-World Function Patterns
In real projects, I lean on a few recurring function patterns:
- Pure calculation functions that borrow inputs and return owned computed values, e.g.,
fn total_price(items: &[Item]) -> f64. - Constructor-style functions, often associated functions like
String::from(...)or custom ones likefn new(name: &str) -> Self, which take borrowed input and return an owned struct. - Validation functions returning
Result<T, E>instead of panicking, so callers can handle errors gracefully:
fn parse_age(input: &str) -> Result<u8, String> {
input.parse::<u8>().map_err(|_| format!("'{input}' is not a valid age"))
}
fn main() {
match parse_age("25") {
Ok(age) => println!("Parsed age: {age}"),
Err(e) => println!("Error: {e}"),
}
match parse_age("abc") {
Ok(age) => println!("Parsed age: {age}"),
Err(e) => println!("Error: {e}"),
}
}
Output:
Parsed age: 25
Error: 'abc' is not a valid age
Best Practices and Idiomatic Patterns
A few habits I’ve picked up:
- Borrow by default, own only when needed. If your function doesn’t need to keep or consume a value, take a reference (
&Tor&strinstead ofString) rather than an owned value. - Prefer returning
Resultover panicking for anything that can plausibly fail based on external input. - Keep functions small and focused. If a function signature is getting long with many parameters, consider grouping related parameters into a struct.
- Use expressions, not extra
returnstatements, for the final value — it’s more idiomatic and slightly more concise, thoughreturnis perfectly valid for early exits.
Common Mistakes and Debugging Tips
The single most common mistake beginners make (I made it constantly) is adding a semicolon after the final expression when you meant to return it:
fn double(x: i32) -> i32 {
x * 2; // BUG: semicolon turns this into a statement returning ()
}
This produces a mismatched types compiler error expecting i32 but finding (). Removing the semicolon fixes it immediately.
Another common issue is calling a function with a moved value:
error[E0382]: use of moved value
The fix is almost always to either pass a reference (&value) instead of the owned value, or to .clone() the value if you genuinely need two independent owned copies (understanding that cloning has a real memory and performance cost, unlike borrowing).
FAQs
Does Rust support default parameter values? No, not directly on functions. The common workarounds are function overloading via traits, builder patterns, or using Option<T> parameters with unwrap_or.
Can Rust functions return multiple values? Yes, using tuples: fn min_max(list: &[i32]) -> (i32, i32) { ... } returns two values packed into a tuple, which the caller can destructure.
What happens if I don’t specify a return type? The function implicitly returns (), the unit type, meaning “no meaningful value.”
Is there a performance cost to passing by reference vs. by value? Passing small Copy types like i32 by value is typically just as fast as by reference (sometimes faster, since it avoids a pointer indirection). For larger types like String or Vec<T>, passing by reference avoids expensive moves or clones and is generally preferred.
Summary
Function definitions in Rust are deliberately explicit: parameters require type annotations, return types are declared with ->, and the distinction between statements and expressions determines what a function actually returns. This explicitness isn’t bureaucracy for its own sake — it’s what lets the compiler enforce Rust’s ownership and borrowing rules at every function boundary, giving you memory safety guarantees at compile time with zero runtime overhead. Once you internalize the statement-versus-expression distinction and get comfortable choosing between owning and borrowing parameters, writing correct, idiomatic Rust functions becomes second nature.
References
- The Rust Programming Language Book, Chapter 3.3: Functions — https://doc.rust-lang.org/book/ch03-03-how-functions-work.html
- The Rust Programming Language Book, Chapter 4: Understanding Ownership — https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- The Rust Reference, Functions — https://doc.rust-lang.org/reference/items/functions.html
- Cargo Documentation — https://doc.rust-lang.org/cargo/
