Beginner

Control Flow in Erlang

Learn how to direct program flow in Erlang using case, if, guards, multi-clause functions, recursion, and list comprehensions with Docker-ready examples

Control flow is how a program decides what to do next. Most languages reach for if, for, and while to make those decisions. Erlang has an if, but as a multi-paradigm functional language it leans on tools that fit immutable data far better: pattern matching, multi-clause functions, case, guards, and recursion.

The biggest mental shift is that Erlang has no mutable loop counters. Because data is immutable you can never write I = I + 1 to rebind an existing variable, so the traditional counting loop does not exist. Iteration is expressed instead through recursion or through the list comprehensions and lists module functions that wrap it. Branching, meanwhile, is often handled by matching the shape of data rather than testing it with conditionals.

Erlang’s if is also unusual: its branches are guards, not arbitrary boolean expressions, and at least one guard must succeed or the expression raises an error. For that reason case and multi-clause functions do most of the real work. In this tutorial you’ll learn how Erlang handles conditionals (case, if), how guards add conditions to clauses, and how recursion replaces the traditional loop. Every example runs as a standalone escript.

Case: Matching on Shape

case compares a value against a series of patterns and runs the first branch that matches. This is the idiomatic replacement for the switch statement, and it does far more because each pattern can destructure data. Clauses are separated by ;, and the _ pattern is a catch-all.

Create a file named case_match.erl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/usr/bin/env escript

main(_) ->
    io:format("~s~n", [action(red)]),
    io:format("~s~n", [action(green)]),
    io:format("~s~n", [action(blue)]).

%% case picks the first matching pattern; _ is the catch-all
action(Color) ->
    case Color of
        red    -> "Stop";
        yellow -> "Slow down";
        green  -> "Go";
        _      -> "Unknown signal"
    end.

Here red, yellow, and green are atoms — constants whose value is their own name, commonly used as labels in Erlang.

If and Guards: Choosing on Conditions

Erlang’s if evaluates a list of guards top to bottom and runs the first one that holds true. Unlike most languages you cannot call arbitrary functions inside a guard — only guard-safe tests like comparisons and rem. The atom true serves as the default branch, guaranteeing the if always has a match.

Create a file named if_guards.erl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env escript

main(_) ->
    Number = 7,
    Parity = if
        Number rem 2 =:= 0 -> "even";
        true               -> "odd"
    end,
    io:format("~p is ~s~n", [Number, Parity]),

    Temp = 30,
    Label = if
        Temp > 35 -> "scorching";
        Temp > 25 -> "warm";
        Temp > 15 -> "mild";
        true      -> "cold"
    end,
    io:format("It is ~s (~p degrees)~n", [Label, Temp]).

Note =:= — Erlang’s exact equality operator, which checks both value and type. Like case, if is an expression: it returns a value you can bind to a variable.

Guards in Function Clauses

The most idiomatic Erlang branching pushes the decision into the function head. Multiple clauses for the same function are tried in order, and a when guard attaches an extra condition to a clause. The right clause is selected automatically — no conditional inside the body at all.

Create a file named pattern_clauses.erl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/usr/bin/env escript

main(_) ->
    io:format("classify(0)  = ~s~n", [classify(0)]),
    io:format("classify(42) = ~s~n", [classify(42)]),
    io:format("classify(-5) = ~s~n", [classify(-5)]).

%% Clauses are tried top to bottom; `when` adds a guard condition
classify(0)            -> "zero";
classify(N) when N > 0 -> "positive";
classify(_)            -> "negative".

The literal 0 in the first clause matches only the number zero, the guarded clause handles positives, and the _ clause catches everything else.

Recursion: Looping the Functional Way

Erlang has no for or while loop that mutates a counter. Repetition is expressed through recursion. Multi-clause functions make this clean: one clause defines the base case that stops the recursion, another does the work and calls itself with a smaller argument.

Create a file named recursion.erl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/usr/bin/env escript

main(_) ->
    countdown(3),
    Sum = sum_list([1, 2, 3, 4, 5]),
    io:format("Sum of 1..5 = ~p~n", [Sum]).

%% Base case stops the recursion; recursive case shrinks the argument
countdown(0) ->
    io:format("Liftoff!~n");
countdown(N) ->
    io:format("~p...~n", [N]),
    countdown(N - 1).

%% Recurse over [Head | Tail] until the list is empty
sum_list([])            -> 0;
sum_list([Head | Tail]) -> Head + sum_list(Tail).

Pattern matching on the literal 0 (and on the empty list []) chooses the stopping clause automatically — no explicit if is needed to decide whether to continue. The [Head | Tail] pattern splits a list into its first element and the rest.

Comprehensions and the Lists Module

In day-to-day code you rarely write raw recursion. List comprehensions and the lists module wrap it for you, giving concise iteration, filtering, and transformation over collections.

Create a file named comprehension.erl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env escript

main(_) ->
    %% Generate 1..10, keep only the values where the filter holds
    Evens = [X || X <- lists:seq(1, 10), X rem 2 =:= 0],
    io:format("Evens: ~p~n", [Evens]),

    %% lists:foreach replaces a counter loop when you only want side effects
    lists:foreach(fun(I) -> io:format("Line ~p~n", [I]) end, lists:seq(1, 3)),

    %% lists:map transforms every element into a new list
    Squares = lists:map(fun(X) -> X * X end, [1, 2, 3, 4]),
    io:format("Squares: ~p~n", [Squares]).

In the comprehension, X <- lists:seq(1, 10) is a generator and X rem 2 =:= 0 is a filter — only elements passing the filter end up in the result list.

Running with Docker

You can run every example without installing Erlang locally.

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

# Run each control-flow example
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript case_match.erl
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript if_guards.erl
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript pattern_clauses.erl
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript recursion.erl
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript comprehension.erl

Expected Output

Running case_match.erl:

Stop
Go
Unknown signal

Running if_guards.erl:

7 is odd
It is warm (30 degrees)

Running pattern_clauses.erl:

classify(0)  = zero
classify(42) = positive
classify(-5) = negative

Running recursion.erl:

3...
2...
1...
Liftoff!
Sum of 1..5 = 15

Running comprehension.erl:

Evens: [2,4,6,8,10]
Line 1
Line 2
Line 3
Squares: [1,4,9,16]

Key Concepts

  • case matches shape — it compares a value against patterns and can destructure data in the same step, replacing the traditional switch; clauses are separated by ;.
  • if evaluates guards — its branches are guard tests, not arbitrary expressions, and true provides the default; at least one branch must succeed or it raises an error.
  • Guards (when) select clauses — attaching conditions to function clauses pushes branching into the function head, keeping bodies free of conditionals.
  • Pattern matching over conditionals — matching literals like 0 or the empty list [] often eliminates the need for an explicit if check entirely.
  • Recursion replaces loops — with no mutable counters, repetition is expressed by a function calling itself toward a base case.
  • [Head | Tail] destructures lists — splitting a list into its first element and remainder is the foundation of most list recursion.
  • Comprehensions and lists[X || X <- List, Condition] and functions like lists:map/2 and lists:foreach/2 wrap recursion for everyday iteration and filtering.
  • Everything returns a valuecase and if are expressions, so their results can be bound directly to a variable.

Running Today

All examples can be run using Docker:

docker pull erlang:alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining