Learning Elixir: Error Handling Basics
<p>I like to think of error handling in Elixir as a reliable postal service.<br> Every package either arrives successfully with its contents (<code>{:ok, value}</code>) or comes back with a clear note explaining the delivery failure (<code>{:error, reason}</code>).<br> Unlike languages that use exceptions as their primary error mechanism, Elixir treats errors as values that flow through your code like any other value.<br> That makes control flow explicit and easier to reason about, especially when you are learning.<br> In this article, we'll explore how this pattern works, why it's idiomatic Elixir, and how to use it to build reliable and readable programs.</p> <blockquote> <p><strong>Note</strong>: The examples in this article use Elixir 1.19.5. While most operations should work across di
I like to think of error handling in Elixir as a reliable postal service. Every package either arrives successfully with its contents ({:ok, value}) or comes back with a clear note explaining the delivery failure ({:error, reason}). Unlike languages that use exceptions as their primary error mechanism, Elixir treats errors as values that flow through your code like any other value. That makes control flow explicit and easier to reason about, especially when you are learning. In this article, we'll explore how this pattern works, why it's idiomatic Elixir, and how to use it to build reliable and readable programs.
Note: The examples in this article use Elixir 1.19.5. While most operations should work across different versions, some functionality might vary.
Table of Contents
-
Introduction
-
The Ok/Error Tuple Pattern
-
Returning Results from Functions
-
Handling Results at the Call Site
-
Chaining Operations with with
-
The Bang Convention
-
Practical Patterns
-
Best Practices
-
Conclusion
-
Further Reading
-
Next Steps
Introduction
Elixir encourages a style of error handling where functions return explicit values for success and failure. I like to think of {:ok, value} and {:error, reason} as two lanes: your code stays in the success lane when things are fine, or moves to the error lane when something goes wrong. The Elixir standard library, as well as most popular libraries, follow this convention consistently.
The Ok/Error Tuple Pattern
The Core Convention
One way I learned to read Elixir code is this: if a function can fail, it often returns a tuple with a status atom as the first element.
Error result
{:error, :not_found} {:error, "invalid input"} {:error, {:validation_failed, [:name, :email]}}`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> {:error, :not_found} {:error, :not_found}
iex> elem({:ok, 42}, 0) :ok
iex> elem({:ok, 42}, 1) 42`
Enter fullscreen mode
Exit fullscreen mode
Why Tagged Tuples?
I found this pattern especially useful because it integrates naturally with pattern matching:
Returns nil on failure - ambiguous!
if id == 1, do: %{name: "Alice"}, else: nil end end
With tagged tuples, the intent is clear
defmodule GoodExample do def find_user(1), do: {:ok, %{name: "Alice"}} def find_user(id), do: {:error, :not_found} end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> GoodExample.find_user(99) {:error, :not_found}
iex> {:ok, user} = GoodExample.find_user(1) {:ok, %{name: "Alice"}} iex> "Found: #{user.name}" "Found: Alice"`
Enter fullscreen mode
Exit fullscreen mode
Standard Library Examples
I keep seeing this convention across the Elixir standard library:
def demonstrate_integer_parse do case Integer.parse("42") do {number, ""} -> {:ok, number} {number, rest} -> {:ok, number} # partial parse :error -> {:error, "not a number"} end end
def demonstrate_map_access do map = %{name: "Alice"} case Map.fetch(map, :name) do {:ok, name} -> "Name is: #{name}" :error -> "Key not found" end end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> StandardLibraryExamples.demonstrate_integer_parse() {:ok, 42}
iex> StandardLibraryExamples.demonstrate_map_access() "Name is: Alice"
Direct module function calls for comparison:
iex> File.read("definitely_missing_file.txt") {:error, :enoent}
iex> Map.fetch(%{name: "Alice"}, :name) {:ok, "Alice"}
iex> Map.fetch(%{name: "Alice"}, :email) :error
iex> Integer.parse("42") {42, ""}
iex> Integer.parse("not a number") :error
iex> Integer.parse("42abc") {42, "abc"}`
Enter fullscreen mode
Exit fullscreen mode
Returning Results from Functions
Basic Function Patterns
def find(id) do case Map.fetch(@users, id) do {:ok, user} -> {:ok, user} :error -> {:error, :not_found} end end
def find_active(id) do case find(id) do {:ok, %{active: true} = user} -> {:ok, user} {:ok, %{active: false}} -> {:error, :user_inactive} {:error, reason} -> {:error, reason} end end
def list_all do {:ok, Map.values(@users)} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> UserRepository.find(99) {:error, :not_found}
iex> UserRepository.find_active(1) {:ok, %{id: 1, name: "Alice", email: "[email protected]", active: true}}
iex> UserRepository.find_active(2) {:error, :user_inactive}`
Enter fullscreen mode
Exit fullscreen mode
Validation Functions
def validate_email(other) do {:error, :email_must_be_string} end
def validate_age(age) when is_integer(age) and age >= 0 and age <= 150 do {:ok, age} end
def validate_age(age) when is_integer(age) do {:error, {:age_out_of_range, age}} end
def validate_age(other) do {:error, :age_must_be_integer} end
def validate_name(name) when is_binary(name) and byte_size(name) > 0 do trimmed = String.trim(name) if byte_size(trimmed) > 0 do {:ok, trimmed} else {:error, :name_cannot_be_blank} end end
def validate_name(other) do {:error, :name_must_be_string} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> UserValidator.validate_email("[email protected]") {:ok, "[email protected]"}
iex> UserValidator.validate_email("not-an-email") {:error, {:invalid_email, "not-an-email"}}
iex> UserValidator.validate_age(25) {:ok, 25}
iex> UserValidator.validate_age(-5) {:error, {:age_out_of_range, -5}}
iex> UserValidator.validate_name(" Alice ") {:ok, "Alice"}
iex> UserValidator.validate_name(" ") {:error, :name_cannot_be_blank}`
Enter fullscreen mode
Exit fullscreen mode
Enriching Error Reasons
Error reasons can carry as much context as needed:
def parse_config(other) do {:error, {:invalid_input, "config must be a map"}} end
defp extract_host(%{"host" => host}) when is_binary(host) and byte_size(host) > 0 do {:ok, host} end
defp extract_host(%{"host" => host}) do {:error, {:invalid_field, :host, "must be a non-empty string, got: #{inspect(host)}"}} end
defp extract_host(map) do {:error, {:missing_field, :host}} end
defp extract_port(%{"port" => port}) when is_integer(port) and port > 0 and port <= 65535 do {:ok, port} end
defp extract_port(%{"port" => port}) do {:error, {:invalid_field, :port, "must be integer 1-65535, got: #{inspect(port)}"}} end
defp extract_port(map) do {:ok, 4000} # default port end
defp extract_timeout(%{"timeout" => timeout}) when is_integer(timeout) and timeout > 0 do {:ok, timeout} end
defp extract_timeout(%{"timeout" => timeout}) do {:error, {:invalid_field, :timeout, "must be a positive integer, got: #{inspect(timeout)}"}} end
defp extract_timeout(map) do {:ok, 5000} # default timeout end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> DataParser.parse_config(%{"host" => "localhost"}) {:ok, %{host: "localhost", port: 4000, timeout: 5000}}
iex> DataParser.parse_config(%{"host" => "", "port" => 8080}) {:error, {:invalid_field, :host, "must be a non-empty string, got: """}}
iex> DataParser.parse_config(%{"port" => 8080}) {:error, {:missing_field, :host}}
iex> DataParser.parse_config("not a map") {:error, {:invalid_input, "config must be a map"}}`
Enter fullscreen mode
Exit fullscreen mode
Handling Results at the Call Site
Using case
The most explicit way to handle a result is with case:
{:error, :not_found} -> IO.puts("Error: User #{user_id} does not exist")
{:error, reason} -> IO.puts("Unexpected error: #{inspect(reason)}") end end
def create_user(params) do case validate_and_create(params) do {:ok, user} -> IO.puts("Created user: #{inspect(user)}") {:ok, user}
{:error, :validation_failed, errors} -> IO.puts("Validation failed: #{inspect(errors)}") {:error, :validation_failed, errors}
{:error, reason} -> IO.puts("Failed to create user: #{inspect(reason)}") {:error, reason} end end
defp validate_and_create(%{name: name, email: email}) do with {:ok, valid_name} <- UserValidator.validate_name(name), {:ok, valid_email} <- UserValidator.validate_email(email) do {:ok, %{name: valid_name, email: valid_email}} end end
defp validate_and_create(params) do {:error, :missing_required_fields} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> UserController.show_user(99) Error: User 99 does not exist :ok
iex> UserController.create_user(%{name: "Charlie", email: "[email protected]"}) Created user: %{email: "[email protected]", name: "Charlie"} {:ok, %{email: "[email protected]", name: "Charlie"}}
iex> UserController.create_user(%{name: "", email: "[email protected]"}) Failed to create user: :name_must_be_string {:error, :name_must_be_string}`
Enter fullscreen mode
Exit fullscreen mode
Pattern Matching in Function Clauses
You can also handle results directly through function clause pattern matching:
defmodule ResultProcessor do
Handle success
def process_result({:ok, value}) do "Success: #{inspect(value)}" end
Handle specific errors
def process_result({:error, :not_found}) do "The requested item was not found" end
def process_result({:error, :unauthorized}) do "You do not have permission to perform this action" end
Handle generic errors
def process_result({:error, reason}) do "An error occurred: #{inspect(reason)}" end
Batch processing
def process_many(results) do Enum.map(results, fn result -> case result do {:ok, value} -> {:processed, value} {:error, reason} -> {:failed, reason} end end) end
Separate successes from errors
def partition_results(results) do Enum.split_with(results, fn {:ok, _} -> true {:error, _} -> false end) end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> ResultProcessor.process_result({:error, :not_found}) "The requested item was not found"
iex> ResultProcessor.process_result({:error, :something_else}) "An error occurred: :something_else"
iex> results = [{:ok, 1}, {:error, :bad}, {:ok, 2}, {:error, :worse}, {:ok, 3}] [{:ok, 1}, {:error, :bad}, {:ok, 2}, {:error, :worse}, {:ok, 3}]
iex> ResultProcessor.partition_results(results) {[{:ok, 1}, {:ok, 2}, {:ok, 3}], [{:error, :bad}, {:error, :worse}]}`
Enter fullscreen mode
Exit fullscreen mode
Extracting Values Safely
Sometimes it's useful to extract values directly or apply transformations:
defmodule SafeExtractor do
Extract the value or return a default
def unwrap_or({:ok, value}, _default), do: value def unwrap_or({:error, _reason}, default), do: default
Transform the value inside {:ok, }
def map({:ok, value}, fun), do: {:ok, fun.(value)} def map({:error, _} = error, _fun), do: error end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> SafeExtractor.unwrap_or({:error, :not_found}, "default") "default"
iex> SafeExtractor.map({:ok, 5}, fn x -> x * 2 end) {:ok, 10}*
iex> SafeExtractor.map({:error, :bad}, fn x -> x * 2 end) {:error, :bad}`*
Enter fullscreen mode
Exit fullscreen mode
Chaining Operations with with
I found with very helpful for chaining operations that return {:ok, value} or {:error, reason}. It stops at the first failure, which keeps the flow easy to follow.
Basic with Usage
defp create_user_record(name, email, age) do
Simulate saving to database
user = %{id: :rand.uniform(1000), name: name, email: email, age: age} {:ok, user} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> RegistrationService.register_user(%{name: "", email: "[email protected]", age: 28}) {:error, :name_cannot_be_blank}
iex> RegistrationService.register_user(%{name: "Alice", email: "not-valid", age: 28}) {:error, {:invalid_email, "not-valid"}}
iex> RegistrationService.register_user(%{name: "Alice", email: "[email protected]", age: -5}) {:error, {:age_out_of_range, -5}}`
Enter fullscreen mode
Exit fullscreen mode
with and Custom Error Handling
The else clause in with lets you handle errors in one place:
{:error, :user_inactive} -> {:error, "User account is not active"}
{:error, {:insufficient_stock, available}} -> {:error, "Only #{available} items in stock"}
{:error, reason} -> {:error, "Order failed: #{inspect(reason)}"} end end
defp find_product(id) do products = %{ 101 => %{id: 101, name: "Elixir Book", price: 49.90, stock: 5}, 102 => %{id: 102, name: "Erlang Book", price: 39.90, stock: 0} }
case Map.fetch(products, id) do {:ok, product} -> {:ok, product} :error -> {:error, :not_found} end end
defp check_stock(%{stock: stock}, quantity) when stock >= quantity do {:ok, stock} end
defp check_stock(%{stock: stock}, quantity) do {:error, {:insufficient_stock, stock}} end
defp create_order(user, product, stock, quantity) do order = %{ id: :rand.uniform(10000), user_id: user.id, product_id: product.id, quantity: quantity, total: product.price * quantity }*
{:ok, order} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> OrderService.place_order(99, 101, 2) {:error, "User or product not found"}
iex> OrderService.place_order(2, 101, 2) {:error, "User account is not active"}
iex> OrderService.place_order(1, 102, 1) {:error, "Only 0 items in stock"}`
Enter fullscreen mode
Exit fullscreen mode
Mixing Different Result Shapes
Sometimes you need to handle results from functions that don't return {:ok, _} / {:error, _}. For example, a validator might return just :ok or {:error, }. In these cases, you can tag the results in the with clauses:
defp validate_schema(%{valid: true}), do: :ok defp validate_schema(), do: {:error, ["validation failed"]}
defp save_to_db(%{save_ok: true}), do: {:ok, %{saved: true}} defp save_to_db(), do: {:error, :db_error} end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> MixedResultShapes.process_data(%{valid: false, save_ok: true}) {:error, {:validation_error, ["validation failed"]}}
iex> MixedResultShapes.process_data(%{valid: true, save_ok: false}) {:error, {:save_error, :db_error}}
Direct tuple examples for reference:
iex> {:validate, :ok} {:validate, :ok}
iex> {:validate, {:error, ["name is required"]}} {:validate, {:error, ["name is required"]}}
iex> {:save, {:ok, %{name: "Alice"}}} {:save, {:ok, %{name: "Alice"}}}`
Enter fullscreen mode
Exit fullscreen mode
The Bang Convention
Functions with ! Suffix
In Elixir, functions with a ! suffix usually raise an exception on failure instead of returning {:error, reason}. I use this when I expect success and want failures to surface quickly.
defmodule FileProcessor do
Returns {:ok, contents} or {:error, reason}
def read_file(path) do File.read(path) end
Raises on failure
def read_file!(path) do File.read!(path) end
Custom bang version
def find_user(id) do case UserRepository.find(id) do {:ok, user} -> {:ok, user} {:error, reason} -> {:error, reason} end end
def find_user!(id) do case find_user(id) do {:ok, user} -> user {:error, :not_found} -> raise "User #{id} not found" {:error, reason} -> raise "Failed to find user: #{inspect(reason)}" end end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
Enter fullscreen mode
Exit fullscreen mode
When to Use Each
I've found a simple rule helpful: I use the non-bang version when failure is an expected part of the business logic (for example, user not found or invalid input). I use the bang version in initialization or setup code, where a failure means the program can't continue.
defmodule ConfigExample do
Non-bang: failure is expected and handled by the caller
def find_config(key), do: Map.fetch(config(), key)
Bang: used in startup, failure is catastrophic
def load_required_config!(key) do case find_config(key) do {:ok, value} -> value :error -> raise "Required config key #{inspect(key)} is missing" end end
defp config do %{app_name: "blog_elixir", version: "1.0.0"} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> ConfigExample.find_config(:missing_key) :error
iex> ConfigExample.load_required_config!(:app_name) "blog_elixir"
iex> ConfigExample.load_required_config!(:missing_key) ** (RuntimeError) Required config key :missing_key is missing**
Direct Map.fetch examples for reference:
iex> Map.fetch(%{app_name: "blog_elixir"}, :app_name) {:ok, "blog_elixir"}
iex> Map.fetch(%{app_name: "blog_elixir"}, :missing) :error`
Enter fullscreen mode
Exit fullscreen mode
Practical Patterns
A Complete Service Layer
def create_account(attrs) do with {:ok, username} <- validate_username(attrs[:username]), {:ok, email} <- UserValidator.validate_email(attrs[:email]), {:ok, password} <- validate_password(attrs[:password]), :ok <- check_username_availability(username), :ok <- check_email_availability(email), {:ok, account} <- persist_account(username, email, password) do {:ok, account} end end
defp validate_username(username) when is_binary(username) do trimmed = String.trim(username) cond do byte_size(trimmed) < 3 -> {:error, {:username_too_short, min: 3}} byte_size(trimmed) > 30 -> {:error, {:username_too_long, max: 30}} not String.match?(trimmed, ~r/^[a-zA-Z0-9_]+$/) -> {:error, :username_invalid_characters} true -> {:ok, String.downcase(trimmed)} end end_
defp validate_username(other) do {:error, :username_must_be_string} end
defp validate_password(password) when is_binary(password) do if byte_size(password) >= @min_password_length do {:ok, hash_password(password)} else {:error, {:password_too_short, min: @min_password_length}} end end
defp validate_password(other) do {:error, :password_must_be_string} end
defp check_username_availability(username) do
Simulate checking database
taken_usernames = ["admin", "root", "elixir"] if username in taken_usernames do {:error, {:username_taken, username}} else :ok end end
defp check_email_availability(email) do
Simulate checking database
:ok end
defp persist_account(username, email, password_hash) do account = %{ id: :rand.uniform(100_000), username: username, email: email, password_hash: password_hash, created_at: DateTime.utc_now() } {:ok, account} end
defp hash_password(password) do
Simulation - in real code use Bcrypt or Argon2
:crypto.hash(:sha256, password) |> Base.encode64() end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> AccountService.create_account(%{username: "admin", email: "[email protected]", password: "securepass123"}) {:error, {:username_taken, "admin"}}
iex> AccountService.create_account(%{username: "ab", email: "[email protected]", password: "securepass123"}) {:error, {:username_too_short, [min: 3]}}
iex> AccountService.create_account(%{username: "valid_user", email: "[email protected]", password: "short"}) {:error, {:password_too_short, [min: 8]}}`
Enter fullscreen mode
Exit fullscreen mode
Collecting Multiple Errors
Sometimes you want to validate all fields and return all errors at once instead of stopping at the first failure:
if Enum.empty?(errors) do {:ok, sanitize_params(params)} else {:error, {:validation_failed, errors}} end end
defp validate_field(errors, field, value, validator) do case validator.(value) do {:ok, valid_value} -> errors {:error, reason} -> [{field, reason} | errors] end end
defp sanitize_params(params) do params |> Map.update(:name, nil, &String.trim/1) |> Map.update(:email, nil, &String.downcase/1) end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> FormValidator.validate_registration_form(%{name: "", email: "not-an-email", age: -5}) {:error, {:validation_failed, [ age: {:age_out_of_range, -5}, email: {:invalid_email, "not-an-email"}, name: :name_must_be_string ]}}`
Enter fullscreen mode
Exit fullscreen mode
Working with Lists of Results
case Enum.split_with(results, fn {:ok, _} -> true {:error, _} -> false other -> false end) do {successes, []} -> values = Enum.map(successes, fn {:ok, v} -> v end) {:ok, values}
{successes, failures} -> errors = Enum.map(failures, fn {:error, reason} -> reason other -> {:unexpected_result, other} end)
{:partial, Enum.map(successes, fn {:ok, v} -> v end), errors} end end
Fails fast: stops at the first error
def process_all_strict(items) do Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} -> case process_one(item) do {:ok, result} -> {:cont, {:ok, [result | acc]}} {:error, reason} -> {:halt, {:error, reason}} end end) |> case do {:ok, results} -> {:ok, Enum.reverse(results)} error -> error end end
defp process_one(item) when is_integer(item) and item > 0 do {:ok, item * 2} end*
defp process_one(item) do {:error, {:invalid_item, item}} end end`
Enter fullscreen mode
Exit fullscreen mode
Testing in IEx:
iex> BatchProcessor.process_all([1, -2, 3, -4, 5]) {:partial, [2, 6, 10], [{:invalid_item, -2}, {:invalid_item, -4}]}
iex> BatchProcessor.process_all_strict([1, 2, 3, 4, 5]) {:ok, [2, 4, 6, 8, 10]}
iex> BatchProcessor.process_all_strict([1, -2, 3]) {:error, {:invalid_item, -2}}`
Enter fullscreen mode
Exit fullscreen mode
Best Practices
I return tagged tuples from functions that can fail — this keeps success and failure visible in the return value instead of hidden behind nil or exceptions.
I try to make error reasons descriptive — specific, actionable reasons are easier to debug:
For example, I find {:error, {:missing_field, :email}} much easier to debug than {:error, :bad_input}.
I use with for sequential operations — it gives me a clean pipeline with a single error handling point.
I avoid silently discarding errors — if I assign _ = Repository.insert(user) and ignore the result, I hide failures from myself. I try to propagate or log them._
I try to stay consistent — tagged tuples as default keep code easier to reason about; mixing nil and {:error, } can make behavior harder to predict.
Conclusion
The {:ok, value} and {:error, reason} pattern became one of the most useful conventions for me in Elixir. Writing and testing these examples made me more confident with failure paths.
Some things I learned:
-
Errors are values: I get better results when I treat {:error, reason} like any other return value
-
Descriptive reasons help a lot: Clear error reasons make debugging faster for me
-
with keeps pipelines readable: I can chain steps and handle failures in one place
-
Consistency pays off: Using one style across the codebase makes behavior easier to predict
-
Bang functions fit programming errors: I use ! variants when failure means my assumptions were wrong
-
I try not to discard errors: Propagating or logging failures saves debugging time later
Learning this pattern made my Elixir code easier to reason about, test, and maintain. I still keep refining it, but it already makes day-to-day coding much clearer.
Further Reading
-
Elixir Official Documentation - case, cond, and if
-
Elixir Official Documentation - with
-
Elixir School - Error Handling
-
Programming Elixir by Dave Thomas
Next Steps
After learning the {:ok, value} / {:error, reason} pattern, I felt ready to explore Try, Catch, and Rescue in Elixir. Tagged tuples handle expected failures, while exceptions help with unexpected situations.
In the next article, we'll explore:
-
When to use try/rescue vs tagged tuples
-
Defining and raising custom exceptions
-
Using catch for exits and throws
-
The after clause for cleanup and resource management
-
Practical guidelines for combining both error handling approaches
Understanding exceptions rounds out the error handling toolkit. It helped me separate expected validation errors from truly unexpected runtime failures.
Sign in to highlight and annotate this article

Conversation starters
Daily AI Digest
Get the top 5 AI stories delivered to your inbox every morning.
Knowledge Map
Connected Articles — Knowledge Graph
This article is connected to other articles through shared AI topics and tags.


Discussion
Sign in to join the discussion
No comments yet — be the first to share your thoughts!