← Back to DevBytes

Error Handling Patterns in Erlang

Introduction to Error Handling in Erlang

Erlang, originally designed for building fault-tolerant telecommunications systems, approaches error handling in a fundamentally different way from most programming languages. Rather than trying to prevent errors from occurring through defensive coding at every step, Erlang embraces the reality that errors will happen—especially in complex, distributed systems running for years at a time. This gives rise to a distinctive set of error handling patterns built around process isolation, supervision, and controlled failure recovery.

Understanding these patterns is essential for any developer working with Erlang, Elixir, or other BEAM ecosystem languages. They form the backbone of the platform's legendary reliability and inform everything from small utility modules to massive distributed applications.

Why Error Handling Matters in Erlang

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

In Erlang, processes are the fundamental unit of computation and fault isolation. Each process runs in its own memory space, with its own mailbox and execution context. When a process crashes, it does not corrupt the memory of other processes. This architectural decision makes error handling not just a matter of code correctness, but a core design principle that shapes how entire systems are structured.

Proper error handling patterns allow you to:

Core Error Handling Patterns

1. The "Let It Crash" Philosophy

The most counterintuitive Erlang pattern is also the most important: let it crash. Instead of writing exhaustive defensive code to handle every conceivable error condition within a single function or process, Erlang programmers are encouraged to write code that assumes success and to let the process terminate when an unexpected condition arises. A separate supervisor process then handles the consequences.

This does not mean ignoring errors. It means consciously deciding where to handle them. The idea is that trying to recover from an unexpected error in-place is often more dangerous than restarting from a known good state. Here is a simple example contrasting defensive programming with the "let it crash" approach:

%% ---- Defensive style (not idiomatic Erlang) ----
-module(defensive).
-export([process_message/1]).

process_message(Msg) ->
    case parse(Msg) of
        {ok, Parsed} ->
            case validate(Parsed) of
                {ok, Valid} -> 
                    case store(Valid) of
                        {ok, Result} -> {ok, Result};
                        {error, Reason} -> {error, {store_failed, Reason}}
                    end;
                {error, Reason} -> {error, {validation_failed, Reason}}
            end;
        {error, Reason} -> {error, {parse_failed, Reason}}
    end.
%% Deeply nested case statements, error handling obscures the happy path.
%% ---- Let It Crash style (idiomatic Erlang) ----
-module(crashstyle).
-export([process_message/1]).

process_message(Msg) ->
    Parsed = parse(Msg),    %% crashes if parse fails
    Valid = validate(Parsed), %% crashes if validation fails
    {ok, store(Valid)}.      %% crashes if store fails

%% The function is clean, short, and focused on the success case.
%% Error handling is delegated to the supervisor above.

In the second version, each helper function is expected to either return a valid result or crash. The process terminates on any failure, and its supervisor decides whether to restart it, escalate the error, or take other action. This keeps business logic dramatically simpler.

2. Process Supervision Trees

Supervision trees are the structural foundation of Erlang error handling. A supervisor is a special kind of process whose sole job is to monitor child processes and take action when they terminate. Supervisors can themselves be supervised, forming a hierarchical tree of fault isolation boundaries.

Here is a complete example of a supervision tree with different restart strategies:

-module(my_app_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    %% Define child specifications
    Children = [
        #{id => worker_pool,
          start => {worker_pool, start_link, []},
          restart => permanent,    %% always restarted
          shutdown => 5000,
          type => supervisor,      %% this child is itself a supervisor
          modules => [worker_pool]},
        
        #{id => cache_handler,
          start => {cache_handler, start_link, []},
          restart => transient,    %% restarted only if it terminates abnormally
          shutdown => 2000,
          type => worker,
          modules => [cache_handler]},
        
        #{id => stats_collector,
          start => {stats_collector, start_link, []},
          restart => temporary,    %% never restarted, for one-shot tasks
          shutdown => 1000,
          type => worker,
          modules => [stats_collector]}
    ],
    
    %% Strategy: one_for_one restarts only the crashed child
    %% one_for_all restarts all children if one crashes
    %% rest_for_one restarts the crashed child and all children after it
    {ok, {#{strategy => one_for_one,
            intensity => 5,     %% max 5 restarts
            period => 10},      %% within 10 seconds
          Children}}.

The supervisor restart strategies give you fine-grained control over recovery:

The intensity and period parameters prevent infinite restart loops. If the maximum restart frequency is exceeded, the supervisor itself terminates, escalating the problem to its own supervisor.

3. Try-Catch for Exceptional Code Paths

While "let it crash" is the default, there are situations where you must catch errors within a process—typically at integration boundaries like external APIs, user inputs, or resource allocation. Erlang provides try/catch for these cases:

-module(safe_caller).
-export([call_external_api/1]).

call_external_api(Request) ->
    try
        %% Attempt the risky operation
        Response = http_client:post("https://api.example.com", Request),
        handle_response(Response)
    catch
        %% Match specific exception classes
        exit:{timeout, Details} ->
            {error, {timeout, Details}};
        error:{badmatch, _} = Error ->
            log_error("Badmatch in API handler", Error),
            {error, unexpected_response};
        %% Catch-all for any other exception
        Class:Reason:Stacktrace ->
            log_error("Unhandled exception", {Class, Reason}),
            %% You can re-raise after logging
            erlang:raise(Class, Reason, Stacktrace)
    after
        %% Cleanup always runs, even if an exception is re-raised
        cleanup_resources()
    end.

The after block is crucial—it executes whether the try block succeeds, fails with an exception, or even if you re-raise an exception from the catch block. This makes it the ideal place for resource cleanup like closing file handles, database connections, or socket descriptors.

You can also use the shorthand try...after without a catch block when you only need cleanup but want the exception to propagate:

read_config_file(Path) ->
    {ok, Handle} = file:open(Path, [read]),
    try
        read_all_lines(Handle)
    after
        file:close(Handle)   %% always executed
    end.
%% If read_all_lines crashes, the file is still closed,
%% and the exception propagates to the caller/supervisor.

4. Tagged Tuples for Expected Errors

Not all errors should cause crashes. For expected failure cases—a file not found, a validation failure, a timeout on a network call—the idiomatic Erlang pattern is to return tagged tuples like {ok, Result} or {error, Reason}. This allows the caller to pattern-match on the outcome without resorting to exception handling.

-module(user_repo).
-export([find_by_id/1, save/1]).

%% Expected error: user might not exist
find_by_id(Id) ->
    case db:query("SELECT * FROM users WHERE id = ?", [Id]) of
        [] -> 
            {error, not_found};
        [Row] -> 
            {ok, row_to_user(Row)}
    end.

%% Expected error: validation might fail
save(User) ->
    case validate_user(User) of
        true ->
            Id = db:insert("users", user_to_row(User)),
            {ok, Id};
        false ->
            {error, invalid_user}
    end.

%% ---- Caller handles errors without try/catch ----
-module(user_handler).
-export([get_user/1]).

get_user(Id) ->
    case user_repo:find_by_id(Id) of
        {ok, User} ->
            format_response(User);
        {error, not_found} ->
            {http, 404, "User not found"};
        {error, _Other} ->
            {http, 500, "Internal error"}
    end.

This pattern keeps the error handling path explicit and visible in the code, without the performance overhead of exception handling. It is particularly well-suited for library APIs and pure functions.

5. Timeouts and Defensive Messaging

In concurrent systems, a process may wait indefinitely for a message that never arrives. Defensive patterns with after clauses in receive blocks prevent processes from becoming stuck:

-module(request_handler).
-export([request/2]).

%% Send a request and wait for a response with a timeout
request(ServerPid, Data) ->
    Ref = make_ref(),
    ServerPid ! {request, Ref, Data, self()},
    %% Wait for response or timeout
    receive
        {response, Ref, Result} ->
            {ok, Result};
        {error, Ref, Reason} ->
            {error, Reason}
    after 5000 ->
        %% Timeout: clean up and return error
        {error, timeout}
    end.

%% More sophisticated: selective receive with multiple timeouts
wait_for_responses(ReqRefs) ->
    Responses = wait_for_responses(ReqRefs, #{}).

wait_for_responses([], Acc) ->
    maps:values(Acc);  %% all responses collected
wait_for_responses(Pending, Acc) ->
    receive
        {response, Ref, Result} ->
            case maps:is_key(Ref, Pending) of
                true ->
                    NewPending = maps:remove(Ref, Pending),
                    NewAcc = maps:put(Ref, Result, Acc),
                    wait_for_responses(NewPending, NewAcc);
                false ->
                    %% Stale/delayed message for an old request, ignore it
                    wait_for_responses(Pending, Acc)
            end
    after 10000 ->
        %% Partial timeout: return what we have plus timeout errors
        TimeoutResults = [{Ref, timeout} || Ref <- maps:keys(Pending)],
        maps:values(Acc) ++ TimeoutResults
    end.

Using unique references (make_ref()) is a critical defensive practice. Without them, a process might accidentally match a message intended for a different request cycle, leading to subtle state corruption. Always include a unique tag in request-response patterns.

6. The After Clause for Resource Cleanup

Resource cleanup is one of the trickiest aspects of error handling in any language. Erlang's after clause guarantees execution regardless of how the preceding block exits:

-module(file_processor).
-export([process_file/1]).

process_file(Path) ->
    %% Acquire resource
    {ok, File} = file:open(Path, [read, write]),
    try
        {ok, Content} = file:read(File, 1024),
        Modified = transform(Content),
        file:write(File, Modified),
        {ok, processed}
    after
        %% Guaranteed cleanup, even if transform/1 crashes
        file:close(File)
    end.

%% Multiple resources with nested after blocks
process_with_temp(Path) ->
    {ok, Source} = file:open(Path, [read]),
    try
        TempPath = create_temp_file(),
        {ok, Temp} = file:open(TempPath, [write]),
        try
            copy_and_transform(Source, Temp),
            {ok, TempPath}
        after
            file:close(Temp)
        end
    after
        file:close(Source)
    end.

This pattern is far more reliable than scattering close/cleanup calls across multiple code paths. The after block provides a single point of cleanup that the runtime guarantees to execute.

7. Linking and Monitoring Processes

Erlang provides two mechanisms for processes to observe each other's fate: links and monitors. Understanding when to use each is essential for building robust systems.

Links are bidirectional and propagate exits. When two processes are linked, if one terminates, the other receives an exit signal and (by default) also terminates. Links are the glue that binds supervision trees together—supervisors are linked to their children, so they receive exit signals when children crash.

-module(linked_workers).
-export([start_workers/0, worker/1]).

start_workers() ->
    Worker1 = spawn_link(fun() -> worker(1) end),
    Worker2 = spawn_link(fun() -> worker(2) end),
    %% If Worker1 crashes, Worker2 will also receive an exit signal
    %% and terminate (unless it traps exits).
    {Worker1, Worker2}.

worker(Id) ->
    receive
        {task, Data} ->
            process_task(Id, Data),
            worker(Id);
        stop ->
            ok
    end.

Monitors are unidirectional and do not propagate exits. The monitoring process receives a {'DOWN', Ref, process, Pid, Reason} message when the monitored process terminates, but does not crash itself:

-module(process_monitor).
-export([monitor_worker/1]).

monitor_worker(WorkerPid) ->
    Ref = erlang:monitor(process, WorkerPid),
    %% Worker can crash without affecting us
    receive
        {'DOWN', Ref, process, WorkerPid, Reason} ->
            log_error("Worker ~p terminated: ~p", [WorkerPid, Reason]),
            %% Decide what to do: restart, alert, ignore
            spawn_new_worker()
    end.

To prevent a linked process from killing you when it dies, you can trap exits by setting the process flag:

%% Make a process resilient to linked processes crashing
init() ->
    process_flag(trap_exit, true),
    %% Now exit signals from linked processes arrive as messages
    %% rather than causing immediate termination
    loop().

loop() ->
    receive
        {'EXIT', FromPid, Reason} ->
            handle_exit(FromPid, Reason),
            loop();
        Other ->
            handle_message(Other),
            loop()
    end.

This is how supervisors work internally—they trap exits so they can implement restart logic rather than crashing along with their children.

8. Error Logging and Tracing

When errors occur, you need visibility. Erlang provides built-in logging facilities that integrate with the error handling patterns:

-module(error_logger_example).
-export([run/0]).

run() ->
    %% Application-level logging
    error_logger:info_msg("Application started at ~p", [calendar:local_time()]),
    
    try
        critical_operation()
    catch
        Class:Reason:Stacktrace ->
            %% Detailed error report with stack trace
            error_logger:error_report([
                {error_type, Class},
                {reason, Reason},
                {stacktrace, Stacktrace},
                {module, ?MODULE},
                {timestamp, os:system_time()}
            ]),
            %% Also available: warning_report, info_report
            {error, logged}
    end.

%% For SASL-compliant applications (OTP release handling)
%% errors are automatically logged to the SASL error logger
%% when processes crash in supervision trees.

In OTP applications, crash reports from supervised processes are automatically logged by the SASL (System Architecture Support Libraries) error logger, giving you structured crash reports with process state, message queue contents, and stack traces—all without adding explicit logging to every process.

Best Practices

Drawing from decades of production experience, here are the distilled best practices for error handling in Erlang systems:

Putting It All Together: A Complete Example

Here is a complete, runnable example demonstrating multiple error handling patterns working together in a small OTP-style application:

-module(chat_room).
-behaviour(gen_server).
-export([start_link/0, init/1, handle_call/3, handle_cast/2, 
         handle_info/2, terminate/2]).
-export([join/2, leave/2, send_message/3]).

%% ---- Client API using tagged tuples ----
join(Pid, UserId) ->
    gen_server:call(Pid, {join, UserId}).

leave(Pid, UserId) ->
    gen_server:call(Pid, {leave, UserId}).

send_message(Pid, UserId, Message) ->
    case gen_server:call(Pid, {send, UserId, Message}, 5000) of
        {ok, _} = Result -> Result;
        {error, _} = Error -> Error;
        %% Timeout from gen_server:call is a special exit reason
        {'EXIT', {timeout, _}} -> {error, timeout}
    end.

%% ---- gen_server callbacks ----
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

init([]) ->
    %% Trap exits to handle linked process deaths gracefully
    process_flag(trap_exit, true),
    {ok, #{users => #{}, messages => []}}.

handle_call({join, UserId}, _From, State) ->
    Users = maps:get(users, State),
    case maps:is_key(UserId, Users) of
        true ->
            {reply, {error, already_joined}, State};
        false ->
            %% Link to user process to detect when they crash
            monitor_user_process(UserId),
            NewUsers = maps:put(UserId, active, Users),
            {reply, {ok, joined}, State#{users => NewUsers}}
    end;

handle_call({leave, UserId}, _From, State) ->
    Users = maps:get(users, State),
    NewUsers = maps:remove(UserId, Users),
    {reply, {ok, left}, State#{users => NewUsers}};

handle_call({send, UserId, Message}, _From, State) ->
    Users = maps:get(users, State),
    case maps:is_key(UserId, Users) of
        true ->
            %% Try to deliver, handle expected failures
            case try_deliver(UserId, Message) of
                {ok, _} ->
                    {reply, {ok, sent}, State};
                {error, offline} ->
                    {reply, {error, user_offline}, State}
            end;
        false ->
            {reply, {error, not_a_member}, State}
    end.

handle_info({'EXIT', FromPid, Reason}, State) ->
    %% A linked user process crashed
    Users = maps:get(users, State),
    %% Find and remove the crashed user
    NewUsers = maps:filter(fun(_Id, Pid) -> Pid =/= FromPid end, Users),
    error_logger:warning_report([{user_crash, FromPid}, {reason, Reason}]),
    {noreply, State#{users => NewUsers}};

handle_info({'DOWN', _Ref, process, Pid, Reason}, State) ->
    %% A monitored process died (different from EXIT for linked processes)
    handle_dead_process(Pid, Reason, State);

handle_info(_Other, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    %% Cleanup: close any resources held by the server
    ok.

%% ---- Internal helpers ----
monitor_user_process(UserId) ->
    %% Start or locate the user's process and link to it
    {ok, UserPid} = user_session:get_pid(UserId),
    erlang:link(UserPid).

try_deliver(UserId, Message) ->
    try
        {ok, Pid} = user_session:get_pid(UserId),
        Pid ! {chat_message, Message},
        {ok, delivered}
    catch
        error:{badmatch, {error, _}} ->
            {error, offline}
    end.

handle_dead_process(Pid, Reason, State) ->
    %% Handle monitored process death (non-linked)
    error_logger:info_report([{monitored_down, Pid}, {reason, Reason}]),
    {noreply, State}.

This example demonstrates tagged tuples for API responses, try/catch at integration boundaries, linking for fate sharing, monitoring for observation, exit signal trapping, error logging, and the "let it crash" principle applied to the user processes themselves—the chat room server handles their disappearance gracefully rather than crashing alongside them.

Conclusion

Error handling in Erlang is not merely a set of syntax features—it is a philosophy woven into the fabric of the language and runtime. The patterns described here—let it crash, supervision trees, tagged tuples, try/catch with after, timeouts, linking and monitoring, and structured logging—form a cohesive toolkit for building systems that are resilient, maintainable, and transparent in their failure modes.

The key insight is separation of concerns: business logic handles the success path, infrastructure code (supervisors, monitors, cleanup blocks) handles the failure paths. This separation produces code that is dramatically simpler and more reliable than the defensive spaghetti that characterizes error handling in many other languages. By embracing these patterns, you tap into the same design principles that allow Erlang systems to achieve the remarkable uptime figures for which the platform is famous—often measured in years rather than hours.

🚀 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