Functions in Mojo
Learn functions in Mojo - def vs fn, parameters and return types, default and keyword arguments, argument conventions, recursion, and higher-order functions with Docker-ready examples
Functions are how you give a name to a piece of work and reuse it. They let you break a program into small, testable pieces, hide details behind a clear interface, and avoid repeating yourself. Every language has them, but the way a language defines and calls functions reveals a lot about its priorities.
Mojo is unusual here because it offers two kinds of functions. As a Python superset, it keeps Python’s familiar def, where parameter types are optional and the function behaves dynamically. But Mojo also adds fn, a stricter form where types are mandatory and the compiler enforces stronger guarantees—the foundation of Mojo’s systems-level performance. The same return, the same indentation, the same call syntax; the difference is how much the compiler checks for you.
Mojo goes further than Python in one more way that matters for performance: argument conventions. Borrowing ideas from Rust, a function can declare whether it reads an argument, mutates it in place, or takes ownership of it. This gives you C++-level control over copies and references while keeping Python’s readable syntax.
In this tutorial you’ll learn how to define def and fn functions, pass parameters and return values, use default and keyword arguments, control how arguments are passed with read/mut/owned, write recursive functions, and treat functions as first-class values. Every example runs as-is with the Mojo Docker image.
def vs fn: Two Kinds of Functions
The first decision when writing a Mojo function is which keyword to use. A def function is Python-style: parameter and return types are optional. An fn function is strict: every parameter needs a type, and the return type must be declared with -> Type (or omitted entirely when the function returns nothing).
Create a file named functions_basics.mojo:
# A strict fn function: parameter and return types are mandatory
fn add(a: Int, b: Int) -> Int:
return a + b
# A flexible def function, written here with explicit types
def multiply(a: Int, b: Int) -> Int:
return a * b
# A function that returns nothing simply omits the '-> Type'
fn announce(name: String):
print("Calling function:", name)
def main():
announce("add")
print("add(8, 5) =", add(8, 5))
announce("multiply")
print("multiply(6, 7) =", multiply(6, 7))
Both functions are called exactly the same way—add(8, 5) and multiply(6, 7)—but add is fully type-checked at compile time. The announce function shows that a function returning no value just leaves off the -> Type arrow. Use fn when you want the compiler to catch type mistakes and unlock optimized compiled code; reach for def when you want Python’s flexibility.
Default and Keyword Arguments
A parameter can declare a default value, which callers may omit. And like Python, Mojo lets you pass arguments by name, which makes calls self-documenting and lets you skip earlier optional arguments.
Create a file named functions_defaults.mojo:
# 'exponent' defaults to 2 when the caller omits it
fn power(base: Int, exponent: Int = 2) -> Int:
var result = 1
for _ in range(exponent):
result *= base
return result
# Several defaults at once
fn greet(name: String, greeting: String = "Hello", punctuation: String = "!") -> String:
return greeting + ", " + name + punctuation
def main():
print("power(5) =", power(5)) # uses the default exponent of 2
print("power(2, 10) =", power(2, 10)) # overrides the default
# Arguments can be passed by keyword, skipping earlier optional ones
print(greet("Mojo"))
print(greet("Ada", greeting="Welcome"))
print(greet("Grace", punctuation="."))
power(5) squares its argument because exponent falls back to 2, while power(2, 10) supplies its own value. The greet calls show keyword arguments: greet("Grace", punctuation=".") keeps the default greeting but overrides only the punctuation. String concatenation with + builds and returns a new String.
Argument Conventions: read, mut, and owned
This is where Mojo departs from Python. By default, an fn argument is passed as read—an immutable reference, so the function can look at it but not change it, and no copy is made. Declare an argument mut to let the function modify the caller’s variable in place. Declare it owned to give the function its own value to consume freely.
Create a file named functions_arguments.mojo:
# 'read' is the default: an immutable reference, no copy made
fn describe(read value: Int):
print("The value is", value)
# 'mut': the function modifies the caller's variable in place
fn double(mut value: Int):
value *= 2
# 'owned': the function gets its own value to mutate and return
fn shout(owned text: String) -> String:
text += "!!!"
return text
def main():
var number = 10
describe(number)
double(number)
print("After double:", number)
var message = String("listen up")
print(shout(message))
print("Original is unchanged:", message)
describe only reads its argument—the read keyword is optional since it’s the default, but shown here for clarity. double uses mut, so the change to value is visible back in main: number becomes 20. shout takes an owned copy of the string and appends to it; because message is still used afterward, Mojo copies it automatically, leaving the original intact. These conventions let you express exactly how data flows—no copies, in-place mutation, or ownership transfer—without leaving readable syntax.
Recursion
A recursive function calls itself, breaking a problem into smaller versions of itself until it reaches a base case. Mojo handles recursion just like any other language; the only requirement is a base case that stops the recursion.
Create a file named functions_recursion.mojo:
# Classic recursive factorial: n! = n * (n-1)!
fn factorial(n: Int) -> Int:
if n <= 1:
return 1
return n * factorial(n - 1)
# Recursive Fibonacci: each number is the sum of the previous two
fn fibonacci(n: Int) -> Int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
def main():
print("Factorials:")
for i in range(1, 7):
print(i, "! =", factorial(i))
print("First 10 Fibonacci numbers:")
for i in range(10):
print(fibonacci(i), end=" ")
print()
factorial stops when n reaches 1, and fibonacci stops when n is below 2—those base cases prevent infinite recursion. The Fibonacci loop uses print(..., end=" ") to keep all the numbers on one line, then a bare print() adds the final newline. Because both functions are fn with Int types, each call compiles to efficient native integer arithmetic.
Functions as Values: Higher-Order Functions
In Mojo, functions are first-class values—you can pass one function as an argument to another. A parameter whose type is written fn(Int) -> Int accepts any function that takes an Int and returns an Int. This enables higher-order functions: functions that operate on other functions.
Create a file named functions_higher_order.mojo:
fn square(x: Int) -> Int:
return x * x
fn cube(x: Int) -> Int:
return x * x * x
# 'op' is a parameter whose type is itself a function
fn apply_to_each(op: fn(Int) -> Int, count: Int):
for i in range(1, count + 1):
print(op(i), end=" ")
print()
# Apply a function to its own result
fn apply_twice(op: fn(Int) -> Int, value: Int) -> Int:
return op(op(value))
def main():
print("Squares 1-5:")
apply_to_each(square, 5)
print("Cubes 1-5:")
apply_to_each(cube, 5)
print("apply_twice(square, 3) =", apply_twice(square, 3))
print("apply_twice(cube, 2) =", apply_twice(cube, 2))
apply_to_each receives square or cube as its op argument and calls it for each number—the caller decides what operation to perform. apply_twice(square, 3) computes square(square(3)), which is square(9), giving 81. Passing functions around like this is the foundation of functional-style programming, and Mojo supports it with full type checking on the function signature.
Running with Docker
Run any of the examples with the Mojo Docker image. No local Mojo installation is required.
| |
Expected Output
Running functions_basics.mojo:
Calling function: add
add(8, 5) = 13
Calling function: multiply
multiply(6, 7) = 42
Running functions_defaults.mojo:
power(5) = 25
power(2, 10) = 1024
Hello, Mojo!
Welcome, Ada!
Hello, Grace.
Running functions_arguments.mojo:
The value is 10
After double: 20
listen up!!!
Original is unchanged: listen up
Running functions_recursion.mojo:
Factorials:
1 ! = 1
2 ! = 2
3 ! = 6
4 ! = 24
5 ! = 120
6 ! = 720
First 10 Fibonacci numbers:
0 1 1 2 3 5 8 13 21 34
Running functions_higher_order.mojo:
Squares 1-5:
1 4 9 16 25
Cubes 1-5:
1 8 27 64 125
apply_twice(square, 3) = 81
apply_twice(cube, 2) = 512
Key Concepts
- Two function keywords —
defis Python-style with optional types;fnrequires type annotations and unlocks compiled, type-checked code - Return types — declare the return type with
-> Type, or omit it entirely for functions that return nothing - Default arguments — a parameter can supply a default value (
exponent: Int = 2) that callers may omit - Keyword arguments — pass arguments by name to make calls self-documenting and skip earlier optional parameters
- Argument conventions —
read(the default) borrows immutably,mutallows in-place modification, andownedtransfers ownership—Mojo’s Rust-inspired control over copies and references - Recursion — functions can call themselves; always include a base case to stop the recursion
- First-class functions — a parameter typed
fn(Int) -> Intaccepts another function, enabling higher-order functions - Same syntax, stronger guarantees — calling a function looks just like Python, but
fnfunctions are fully type-checked and lower to optimized native code
Running Today
All examples can be run using Docker:
docker pull codearchaeology/mojo:latest
Comments
Loading comments...
Leave a Comment