← Back to DevBytes

Functional Programming in Erlang

Understanding Functional Programming in Erlang

Functional programming in Erlang is a paradigm where computation is treated as the evaluation of mathematical functions, avoiding mutable state and side effects. Erlang was designed from the ground up for building concurrent, fault-tolerant, distributed systems, and its functional nature is central to achieving these goals. Unlike imperative languages where you tell the computer how to do something step by step, functional programming in Erlang focuses on what to compute by composing pure functions.

At its core, Erlang enforces several key functional principles: immutability (variables cannot be changed once bound), first-class functions (functions can be assigned to variables, passed as arguments, and returned from other functions), pattern matching as the primary mechanism for data extraction, and recursion instead of traditional looping constructs. These features combine to produce code that is easier to reason about, test, and parallelize.

Why Functional Programming Matters in Erlang

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The functional paradigm matters in Erlang for several compelling reasons that directly impact the reliability and scalability of production systems:

Immutability Eliminates Whole Classes of Bugs

In Erlang, once a variable is bound to a value, it can never change. This eliminates issues like shared mutable state, race conditions within a single process, and unexpected side effects. When you see a variable in code, you know its value is constant throughout its scope. This property makes debugging dramatically simpler because state changes are explicit through function calls rather than hidden mutations.

% In Erlang, variables are immutable
X = 5,
% X = 6 would cause a compilation error - cannot rebind
Y = X * 2,  % Y is now 10, but X remains 5
% You can pattern-match to destructure
{X, Y} = {5, 10},  % This succeeds

Pure Functions Enable Reliable Concurrency

Erlang's actor-based concurrency model relies on processes that communicate via message passing. Pure functions — those that always produce the same output for the same input without side effects — are inherently thread-safe. They can be executed anywhere without worrying about shared state corruption. This is why Erlang processes can run millions of concurrent activities without locking mechanisms.

% A pure function: same input always yields same output, no side effects
-module(math_utils).
-export([square/1]).

square(X) -> X * X.

% Usage in concurrent context
% spawn(fun() -> io:format("Result: ~p~n", [math_utils:square(5)]) end).

Recursion Models Fault-Tolerant Loops

Erlang processes are typically structured as tail-recursive loops that maintain state through function arguments. This pattern allows processes to crash and be restarted without corrupting global state — a cornerstone of Erlang's famous "let it crash" philosophy.

% Tail-recursive loop maintaining state in function parameters
-module(counter).
-export([start/0, loop/1]).

start() ->
    spawn(fun() -> loop(0) end).

loop(Count) ->
    receive
        {increment, From} ->
            NewCount = Count + 1,
            From ! {ok, NewCount},
            loop(NewCount);  % Tail-recursive call
        {get, From} ->
            From ! {current, Count},
            loop(Count)  % State unchanged, still tail-recursive
    end.

Core Concepts and How to Use Them

Pattern Matching as Data Navigation

Pattern matching is Erlang's primary mechanism for branching logic and extracting data from complex structures. Instead of using conditional statements to check types and values, you describe the shape of data you expect, and Erlang automatically binds variables when a match succeeds.

% Pattern matching in function clauses
-module(greeter).
-export([greet/1]).

greet({person, Name, Age}) when Age < 18 ->
    io:format("Hello young ~s!~n", [Name]);
greet({person, Name, _Age}) ->
    io:format("Hello ~s!~n", [Name]);
greet({robot, Serial}) ->
    io:format("Greetings, unit #~w~n", [Serial]);
greet(_Unknown) ->
    io:format("Hello, stranger!~n", []).

% Example calls:
% greeter:greet({person, "Alice", 15}).  % "Hello young Alice!"
% greeter:greet({robot, 42}).            % "Greetings, unit #42"

Higher-Order Functions and List Operations

Functions are first-class citizens in Erlang. You can bind them to variables, pass them as arguments, and return them from other functions. The lists module provides a rich set of higher-order functions (functions that take or return other functions) for processing collections declaratively.

% Higher-order functions with lists
-module(list_demo).
-export([process_numbers/1, apply_twice/2]).

% Using lists:map/2, lists:filter/2, lists:foldl/3
process_numbers(Numbers) ->
    % Map: transform each element
    Doubled = lists:map(fun(X) -> X * 2 end, Numbers),
    
    % Filter: keep only elements satisfying predicate
    EvenOnly = lists:filter(fun(X) -> X rem 2 =:= 0 end, Doubled),
    
    % Fold: accumulate results (left fold)
    Sum = lists:foldl(fun(X, Acc) -> Acc + X end, 0, EvenOnly),
    
    {Doubled, EvenOnly, Sum}.

% Function returning a function (higher-order)
apply_twice(F, X) ->
    F(F(X)).

% Usage:
% F = fun(X) -> X * 3 end,
% apply_twice(F, 2).  % Returns 18: F(F(2)) = F(6) = 18

List Comprehensions for Declarative Data Generation

List comprehensions provide a concise, mathematical notation for building lists from existing ones. They combine mapping and filtering into a single readable expression, similar to set-builder notation in mathematics.

% List comprehensions
-module(comprehension_demo).
-export([pairs/1, cartesian_product/2]).

% Generate all even numbers from 1 to N, squared
even_squares(N) ->
    [X * X || X <- lists:seq(1, N), X rem 2 =:= 0].

% Cartesian product of two lists
cartesian_product(ListA, ListB) ->
    [{A, B} || A <- ListA, B <- ListB].

% Nested comprehensions with multiple generators
pairs(N) ->
    [{X, Y} || X <- lists:seq(1, N), 
               Y <- lists:seq(X + 1, N), 
               X + Y < 10].

% Usage:
% even_squares(10).  % [4, 16, 36, 64, 100]
% cartesian_product([1,2], [a,b]).  % [{1,a},{1,b},{2,a},{2,b}]

Recursion Instead of Loops

Erlang has no traditional for or while loops. All iteration is achieved through recursion, typically with accumulator patterns for efficiency. Tail recursion is especially important because Erlang applies tail-call optimization, preventing stack growth.

% Recursion patterns in Erlang
-module(recursion_demo).
-export([factorial/1, fibonacci/1, flatten/1]).

% Standard recursion (not tail-recursive)
factorial(0) -> 1;
factorial(N) when N > 0 -> 
    N * factorial(N - 1).

% Tail-recursive version with accumulator
factorial_tr(N) -> factorial_tr(N, 1).

factorial_tr(0, Acc) -> Acc;
factorial_tr(N, Acc) when N > 0 ->
    factorial_tr(N - 1, N * Acc).

% Fibonacci with multiple accumulators
fibonacci(N) -> fib(N, 0, 1).

fib(0, A, _B) -> A;
fib(N, A, B) when N > 0 ->
    fib(N - 1, B, A + B).

% Recursively flatten nested lists
flatten([]) -> [];
flatten([H | T]) when is_list(H) ->
    flatten(H) ++ flatten(T);
flatten([H | T]) ->
    [H | flatten(T)].

% Usage:
% factorial_tr(5).  % 120
% fibonacci(10).    % 55
% flatten([[1,[2]],3,[4,[5,6]]]).  % [1,2,3,4,5,6]

Guards for Constrained Pattern Matching

Guards extend pattern matching by allowing additional constraints on matched values. They appear after the when keyword and can test types, compare values, and call certain built-in functions — but they cannot have side effects.

% Guards in function clauses
-module(guard_demo).
-export([classify/1, safe_divide/2]).

% Type and value guards
classify(X) when is_integer(X), X > 0 ->
    positive_integer;
classify(X) when is_integer(X), X < 0 ->
    negative_integer;
classify(X) when is_float(X) ->
    floating_point;
classify(X) when is_list(X), length(X) == 0 ->
    empty_list;
classify(_) ->
    unknown.

% Guards preventing division by zero
safe_divide(X, Y) when Y =/= 0 ->
    {ok, X / Y};
safe_divide(_X, 0) ->
    {error, division_by_zero}.

% Usage:
% classify(42).      % positive_integer
% safe_divide(10, 2). % {ok, 5.0}
% safe_divide(10, 0). % {error, division_by_zero}

Error Handling the Functional Way

Instead of exceptions for control flow, Erlang encourages tagged tuples like {ok, Result} and {error, Reason} to represent outcomes. This keeps functions pure and forces callers to explicitly handle both success and failure cases through pattern matching.

% Functional error handling with tagged tuples
-module(file_reader).
-export([read_file_safe/1]).

read_file_safe(Filename) ->
    case file:read_file(Filename) of
        {ok, Binary} ->
            % Success path: process the binary
            String = binary_to_list(Binary),
            {ok, String};
        {error, enoent} ->
            {error, "File does not exist"};
        {error, eacces} ->
            {error, "Permission denied"};
        {error, Reason} ->
            {error, {unknown_error, Reason}}
    end.

% Chaining operations with pattern matching
process_file(Filename) ->
    case read_file_safe(Filename) of
        {ok, Content} ->
            % Further processing only on success
            Lines = string:tokens(Content, "\n"),
            {ok, length(Lines)};
        {error, Msg} ->
            io:format("Error: ~s~n", [Msg]),
            {error, Msg}
    end.

Best Practices for Functional Erlang

1. Prefer Tail Recursion for Process Loops

Always structure recursive process loops as tail-recursive calls. This ensures the Erlang VM can optimize the recursion into a simple jump, preventing stack overflow in long-running processes. The accumulator pattern keeps state explicit.

% Good: tail-recursive server loop
-module(best_server).
-export([start/0, loop/1]).

start() ->
    spawn(fun() -> loop(#{}) end).

loop(State) ->
    receive
        {set, Key, Value, From} ->
            NewState = maps:put(Key, Value, State),
            From ! {ok, set},
            loop(NewState);  % Tail call
        {get, Key, From} ->
            Result = maps:get(Key, State, undefined),
            From ! {ok, Result},
            loop(State)  % Tail call, state unchanged
    end.
% This process can run forever without growing the stack

2. Use Pattern Matching Exhaustively

Write function clauses that handle all possible input shapes. The Erlang compiler can warn about non-exhaustive patterns. Add a catch-all clause _ -> only when you explicitly want to handle unexpected input, and log such cases for debugging.

% Exhaustive pattern matching
-module(order).
-export([process/1]).

% Explicit clauses for each expected case
process({create, Item, Quantity}) ->
    validate_and_create(Item, Quantity);
process({update, Id, Changes}) when map_size(Changes) > 0 ->
    apply_changes(Id, Changes);
process({delete, Id}) ->
    remove_item(Id);
process({delete, Id, _Reason}) ->
    % Log the reason then delete
    log_deletion(Id),
    remove_item(Id);
process(Unknown) ->
    % Catch-all with logging for unexpected patterns
    io:format("Unexpected message: ~p~n", [Unknown]),
    {error, invalid_command}.

3. Compose With Higher-Order Functions

Rather than writing recursive functions for every list operation, leverage the standard library's higher-order functions. This makes code more declarative and reduces the chance of errors in custom recursion logic.

% Composing list operations declaratively
-module(pipeline).
-export([transform/1]).

transform(Data) ->
    % Compose multiple operations into a pipeline
    lists:sum(
        lists:map(
            fun(X) -> X * X end,
            lists:filter(
                fun(X) -> X > 0 end,
                Data
            )
        )
    ).

% Even cleaner with function composition
transform_v2(Data) ->
    Positive = lists:filter(fun(X) -> X > 0 end, Data),
    Squared = lists:map(fun(X) -> X * X end, Positive),
    lists:sum(Squared).
% Both versions sum squares of all positive numbers

4. Keep Functions Small and Single-Purpose

Each function should do one thing well. If a function exceeds 10-15 lines, consider extracting helper functions. This improves readability, testability, and reuse. Small functions also make pattern matching more precise.

% Good: small, focused functions
-module(user_processor).
-export([process_user/1]).

% Orchestration function delegates to helpers
process_user(UserData) ->
    Validated = validate_user(UserData),
    Enriched = enrich_with_defaults(Validated),
    format_response(Enriched).

validate_user({user, Name, Age}) when Age >= 0 ->
    {user, Name, Age};
validate_user(_Invalid) ->
    {error, invalid_user}.

enrich_with_defaults({user, Name, Age}) ->
    {user, Name, Age, active, calendar:local_time()}.

format_response({user, Name, Age, Status, Created}) ->
    #{name => Name, age => Age, status => Status, created => Created}.

5. Leverage Records and Maps for Structured Data

Use records for fixed-schema data within modules and maps for flexible key-value structures, especially across module boundaries. Both work seamlessly with pattern matching.

% Records for internal module use
-record(person, {name, age, email}).

-module(person_db).
-export([create/3, get_email/1]).

create(Name, Age, Email) ->
    #person{name = Name, age = Age, email = Email}.

% Pattern matching on record fields
get_email(#person{email = Email}) -> Email.

% Maps for cross-module data exchange
to_map(#person{name = N, age = A, email = E}) ->
    #{name => N, age => A, email => E, type => person}.

6. Test Functions in Isolation

Pure functions are trivially testable. Write unit tests that call functions with various inputs and pattern-match on results. Erlang's EUnit and Common Test frameworks integrate directly with this style.

% EUnit test example for functional code
-module(math_utils_tests).
-include_lib("eunit/include/eunit.hrl").

square_test() ->
    % Pure function: no setup needed, no side effects
    ?assertEqual(4, math_utils:square(2)),
    ?assertEqual(0, math_utils:square(0)),
    ?assertEqual(25, math_utils:square(-5)).

% Testing with pattern matching on results
safe_divide_test() ->
    ?assertEqual({ok, 5.0}, safe_divide(10, 2)),
    ?assertEqual({error, division_by_zero}, safe_divide(10, 0)).

% Property-based testing concept (using proper)
% Tests that square is monotonic for positive numbers
square_monotonic_test() ->
    F = fun(X) -> math_utils:square(X) end,
    % For positive X, X1 > X2 => square(X1) > square(X2)
    ?assert(F(5) > F(3)).

7. Embrace the "Let It Crash" Philosophy

In functional Erlang, defensive programming is often counterproductive. Instead of handling every conceivable error within a function, write pure functions that crash on invalid input and rely on supervisors to restart processes cleanly. This leads to simpler code and more robust systems.

% "Let it crash" approach with supervisors
-module(worker).
-export([start/0, process/1]).

start() ->
    spawn_link(fun() -> worker_loop() end).

worker_loop() ->
    receive
        {task, Data, From} ->
            % Don't defensively check Data here
            % If it's invalid, pattern matching will crash the process
            Result = process(Data),
            From ! {ok, Result},
            worker_loop()
    end.

% Pure processing function - crashes on unexpected input
process({compute, X, Y}) ->
    X * Y;  % Will crash if X or Y aren't numbers
process({transform, List}) ->
    lists:map(fun(X) -> X * 2 end, List).
% The supervisor will restart this worker with clean state

Advanced Functional Techniques

Closures and Partial Application

Erlang supports closures — functions that capture variables from their enclosing scope. This is powerful for creating configurable function factories and partially applying arguments.

% Closures and function factories
-module(closure_demo).
-export([multiplier/1, logger/1]).

% Returns a function that multiplies by a captured factor
multiplier(Factor) ->
    fun(X) -> X * Factor end.

% Creates a logger with a captured prefix
logger(Prefix) ->
    fun(Message) ->
        io:format("[~s] ~s~n", [Prefix, Message])
    end.

% Usage:
% Double = multiplier(2),
% Double(5).  % 10
% ErrorLog = logger("ERROR"),
% ErrorLog("Connection failed").  % [ERROR] Connection failed

Lazy Evaluation with Streams

While Erlang is primarily strict (eager) in evaluation, you can simulate lazy sequences using functions that generate values on demand. This is useful for working with potentially infinite sequences.

% Lazy sequence simulation
-module(lazy_demo).
-export([natural_numbers/0, take/2]).

% Returns a function that generates the next natural number
natural_numbers() ->
    fun() -> natural_numbers_from(0) end.

natural_numbers_from(N) ->
    fun() ->
        {N, natural_numbers_from(N + 1)}
    end.

% Take N elements from a lazy generator
take(0, _Generator) -> [];
take(N, Generator) ->
    {Value, NextGenerator} = Generator(),
    [Value | take(N - 1, NextGenerator)].

% Usage:
% Gen = natural_numbers(),
% take(5, Gen).  % [0, 1, 2, 3, 4]

Folding Over Complex Data Structures

Folds are not limited to lists. You can fold over trees, maps, or any recursive data structure by defining appropriate fold functions that visit each element systematically.

% Folding over a binary tree
-module(tree_fold).
-export([sum_tree/1, map_tree/2]).

% Binary tree representation: {node, Value, Left, Right} | leaf

sum_tree(leaf) -> 0;
sum_tree({node, Value, Left, Right}) ->
    Value + sum_tree(Left) + sum_tree(Right).

% Map over tree, preserving structure
map_tree(_F, leaf) -> leaf;
map_tree(F, {node, Value, Left, Right}) ->
    {node, F(Value), map_tree(F, Left), map_tree(F, Right)}.

% Usage:
% Tree = {node, 5, {node, 3, leaf, leaf}, {node, 7, leaf, leaf}},
% sum_tree(Tree).  % 15
% map_tree(fun(X) -> X * 2 end, Tree).
% % {node, 10, {node, 6, leaf, leaf}, {node, 14, leaf, leaf}}

Conclusion

Functional programming in Erlang is not merely a stylistic choice — it is a foundational design principle that enables the language's most celebrated features: massive concurrency, fault tolerance, and hot code swapping. By embracing immutability, pattern matching, higher-order functions, and recursion, you write code that is inherently safer, easier to reason about, and naturally suited to distributed systems. The patterns explored in this tutorial — from basic recursive loops to advanced closures and lazy evaluation — form the toolkit of every effective Erlang developer. As you continue building systems in Erlang, remember that the functional approach is not about rigidly following rules, but about leveraging these concepts to create software that runs reliably for years, scales effortlessly across nodes, and gracefully handles failure. The functional paradigm, combined with Erlang's actor model, gives you a powerful foundation for building the next generation of resilient, concurrent applications.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles