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:
- Build self-healing systems that recover from failures without human intervention
- Isolate failures so that a crash in one component does not cascade throughout the system
- Maintain runtime observability through structured crash reports and process monitoring
- Separate concerns by keeping error recovery logic distinct from business logic
- Achieve impressive uptimeâErlang systems routinely run for years without restart
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:
- one_for_one: Only the failed child is restarted. Other children are unaffected. Best for independent workers.
- one_for_all: If one child fails, all children are terminated and restarted together. Use when children depend on each other.
- rest_for_one: The failed child and all children started after it are terminated and restarted. Useful for ordered startup dependencies.
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:
- Default to "let it crash" for business logic processes. Write the happy path clearly and let supervisors handle the rest. Only deviate when you have a concrete reason.
- Use tagged tuples for expected errors at module API boundaries. Reserve exceptions for truly unexpected conditions that indicate bugs or unrecoverable states.
- Keep try/catch blocks thin. Wrap only the specific operation that might throw, not entire functions. This keeps the scope of error handling clear and prevents accidentally swallowing unrelated exceptions.
- Never catch an exception just to ignore it silently. If you catch an error, either handle it meaningfully, log it thoroughly, or re-raise it. Silent error suppression is the root of countless production mysteries.
- Always use after blocks for resource cleanup when you acquire file handles, network connections, or other limited resources. Never rely on the happy path alone for cleanup.
- Include unique references in all asynchronous request-response patterns. This prevents stale or misdirected messages from corrupting process state.
- Set timeouts on all receive blocks that wait for external responses. A process blocked forever is a silent failure that can paralyze a system.
- Design your supervision tree intentionally. Group processes that must fail together under
one_for_allsupervisors. Keep independent workers underone_for_onesupervisors. Use restart intensity limits to prevent infinite crash loops. - Use monitors for observation, links for interdependence. Monitor when you want to know about a crash without being affected by it. Link when the processes' fates should be coupled.
- Leverage OTP behaviors (
gen_server,gen_statem,gen_event) which encapsulate many of these patterns and provide battle-tested error handling out of the box.
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.