Intermediate

Functions in Julia

Learn how to define and use functions in Julia - parameters, return values, default and keyword arguments, recursion, higher-order functions, and multiple dispatch

Functions are the heart of Julia. Where many languages treat functions as just a way to organize code, Julia builds its entire programming model around them through multiple dispatch—a function can have many methods, and Julia chooses which one to run based on the types of all the arguments.

This makes functions in Julia unusually expressive. You can write generic code that works across types, extend other people’s functions without inheritance, and compose small functions into fast, readable programs. As a multi-paradigm language, Julia treats functions as first-class values: you can pass them as arguments, return them from other functions, and store them in variables.

In this tutorial you will learn the different ways to define functions, how to pass and return values, how scope works, how to write recursive functions, and how higher-order functions and multiple dispatch shape idiomatic Julia code.

Defining Functions

Julia offers several ways to define a function. The function ... end block is the most general form, while the assignment form is a compact shorthand for one-liners.

Create a file named functions.jl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# Functions in Julia

# Standard function definition with an explicit return
function add(a, b)
    return a + b
end

# The last expression is returned automatically—`return` is optional
function multiply(a, b)
    a * b
end

println("add(3, 4) = ", add(3, 4))
println("multiply(3, 4) = ", multiply(3, 4))

# Compact "assignment form" for short functions
square(x) = x^2
println("square(5) = ", square(5))

# Default positional arguments and keyword arguments.
# Arguments before the `;` are positional; those after are keywords.
function describe(name, greeting="Hello"; punctuation="!")
    return "$greeting, $name$punctuation"
end

println(describe("Julia"))
println(describe("Julia", "Hi"))
println(describe("Julia"; punctuation="?"))

# Returning multiple values as a tuple, then destructuring them
function min_max(numbers)
    return minimum(numbers), maximum(numbers)
end

lo, hi = min_max([4, 1, 7, 3])
println("lo = $lo, hi = $hi")

# Varargs: a parameter ending in `...` collects any number of arguments
function total(nums...)
    s = 0
    for n in nums
        s += n
    end
    return s
end

println("total(1, 2, 3, 4) = ", total(1, 2, 3, 4))

Notice that return is optional: a function returns the value of its last expression. The assignment form square(x) = x^2 is exactly equivalent to a function block—it is not a lesser kind of function.

Recursion

Julia supports recursion directly, and the ternary operator cond ? a : b makes base cases concise. Recursion is the natural way to express many mathematical definitions.

Create a file named recursion.jl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Recursion in Julia

# Factorial using the ternary operator for the base case
function factorial_rec(n)
    n <= 1 ? 1 : n * factorial_rec(n - 1)
end

# Fibonacci using an if block
function fib(n)
    if n < 2
        return n
    end
    return fib(n - 1) + fib(n - 2)
end

println("5! = ", factorial_rec(5))
println("10! = ", factorial_rec(10))
println("fib(10) = ", fib(10))

# Array comprehensions pair nicely with functions
fibs = [fib(n) for n in 0:9]
println("First 10 Fibonacci: ", fibs)

The comprehension [fib(n) for n in 0:9] calls fib for each value in the range 0:9 and collects the results into an array—a compact, functional way to build sequences.

Higher-Order Functions

Because functions are first-class values, you can pass them around and return them. Julia’s map, filter, and reduce, combined with anonymous functions (x -> ...), are the workhorses of functional-style Julia.

Create a file named higher_order.jl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Higher-Order Functions in Julia

# Anonymous function assigned to a variable
double = x -> x * 2
println("double(21) = ", double(21))

numbers = [1, 2, 3, 4, 5, 6]

# map applies a function to every element
squared = map(x -> x^2, numbers)
println("squared = ", squared)

# filter keeps elements where the predicate is true
evens = filter(x -> x % 2 == 0, numbers)
println("evens = ", evens)

# reduce combines elements with a binary function
total = reduce(+, numbers)
println("sum = ", total)

# Functions can be passed as arguments
function apply_twice(f, x)
    return f(f(x))
end

println("apply_twice(double, 5) = ", apply_twice(double, 5))

# Closures: a function that returns a function, capturing `factor`
function multiplier(factor)
    return x -> x * factor
end

triple = multiplier(3)
println("triple(10) = ", triple(10))

# The do-block passes a multi-line function as the first argument
result = map([1, 2, 3]) do x
    x^2 + 1
end
println("do-block result = ", result)

The do block is syntactic sugar: map([1, 2, 3]) do x ... end is the same as map(x -> ..., [1, 2, 3]), but it lets you write a multi-line function body cleanly. The multiplier function returns a closure that “remembers” the factor it was created with.

Multiple Dispatch

Multiple dispatch is Julia’s defining feature. A single function name can have many methods, each specialized for different argument types. Julia picks the most specific matching method at call time, based on the types of all arguments—not just the first.

Create a file named dispatch.jl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Multiple Dispatch in Julia

# Several methods of one function, chosen by argument types
collide(a::Int, b::Int) = "two integers: $(a + b)"
collide(a::String, b::String) = "two strings: $a$b"
collide(a::Int, b::String) = "int and string: $a and $b"

println(collide(1, 2))
println(collide("a", "b"))
println(collide(7, "hello"))

# Dispatch on custom types
struct Dog end
struct Cat end

speak(::Dog) = "Woof!"
speak(::Cat) = "Meow!"

println(speak(Dog()))
println(speak(Cat()))

# Count how many methods a function has
println("Methods of collide: ", length(methods(collide)))

# A generic fallback: less specific methods catch everything else
describe_num(x::Integer) = "$x is an integer"
describe_num(x::AbstractFloat) = "$x is a float"
describe_num(x) = "$x is something else"

println(describe_num(42))
println(describe_num(3.14))
println(describe_num("text"))

This is fundamentally different from object-oriented method dispatch, where the method is chosen by a single receiver object. Here, collide(7, "hello") selects the (Int, String) method by examining both arguments. The untyped describe_num(x) method acts as a fallback for any type not matched more specifically.

Variable Scope

Functions introduce a new local scope. Assigning to a variable inside a function creates a new local variable by default—it does not modify a global of the same name unless you explicitly say global.

Create a file named scope.jl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Variable Scope in Julia

# A global variable
counter = 0

function increment_local()
    counter = 5          # creates a NEW local variable; the global is untouched
    return counter
end

println("Local result: ", increment_local())
println("Global counter: ", counter)

# Use `global` to modify a global variable from inside a function
function increment_global()
    global counter
    counter += 1
    return counter
end

println("After increment_global: ", increment_global())
println("Global counter now: ", counter)

# Local variables are not visible outside the function
function compute()
    temp = 100           # local to compute
    return temp * 2
end

println("compute() = ", compute())

This default protects you from accidentally clobbering globals. In practice, idiomatic Julia avoids mutable globals entirely—passing values as arguments and returning results keeps functions pure and fast.

Running with Docker

You can run each example using the official Julia image without installing anything locally:

1
2
3
4
5
6
7
8
9
# Pull the official image
docker pull julia:1.11-alpine

# Run each example
docker run --rm -v $(pwd):/app -w /app julia:1.11-alpine julia functions.jl
docker run --rm -v $(pwd):/app -w /app julia:1.11-alpine julia recursion.jl
docker run --rm -v $(pwd):/app -w /app julia:1.11-alpine julia higher_order.jl
docker run --rm -v $(pwd):/app -w /app julia:1.11-alpine julia dispatch.jl
docker run --rm -v $(pwd):/app -w /app julia:1.11-alpine julia scope.jl

Expected Output

Running functions.jl:

add(3, 4) = 7
multiply(3, 4) = 12
square(5) = 25
Hello, Julia!
Hi, Julia!
Hello, Julia?
lo = 1, hi = 7
total(1, 2, 3, 4) = 10

Running recursion.jl:

5! = 120
10! = 3628800
fib(10) = 55
First 10 Fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Running higher_order.jl:

double(21) = 42
squared = [1, 4, 9, 16, 25, 36]
evens = [2, 4, 6]
sum = 21
apply_twice(double, 5) = 20
triple(10) = 30
do-block result = [2, 5, 10]

Running dispatch.jl:

two integers: 3
two strings: ab
int and string: 7 and hello
Woof!
Meow!
Methods of collide: 3
42 is an integer
3.14 is a float
text is something else

Running scope.jl:

Local result: 5
Global counter: 0
After increment_global: 1
Global counter now: 1
compute() = 200

Key Concepts

  • Two definition forms — Use function ... end for general functions and the compact f(x) = ... assignment form for one-liners; they are fully equivalent.
  • Implicit return — A function returns the value of its last expression; return is only needed to exit early.
  • Flexible arguments — Julia supports default positional arguments, keyword arguments (after ;), and varargs (args...) in a single signature.
  • Multiple return values — Return a tuple and destructure it with lo, hi = min_max(xs).
  • First-class functions — Functions can be passed as arguments, returned as closures, and used with map, filter, and reduce; anonymous functions use x -> ....
  • Multiple dispatch — A function name can have many methods selected by the types of all arguments, enabling generic, extensible code without inheritance.
  • Scope safety — Assignment inside a function creates a local variable by default; use global to modify a global explicitly.
  • The do-blockf(args) do x ... end is a clean way to pass a multi-line function as the first argument.

Running Today

All examples can be run using Docker:

docker pull julia:1.11-alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining