Exceptions
This lesson covers failure as a first-class subject: which exception types the standard library raises, how to signal failure from your own code, how to catch exactly what you mean, how to guarantee cleanup, and how to distinguish a recoverable condition from a bug. It also covers the half of the topic that is usually skipped — when not to use exceptions, and how validation functions such as tryparse and isnothing keep ordinary control flow out of the exception table.
Errors, Exceptions, and Failure
Julia does not distinguish "errors" from "exceptions" at the language level: there is one mechanism, and every raised condition is an instance of an Exception subtype. What differs is the intent behind each throw, and the type is how that intent is expressed.
The Exception Hierarchy
Exception is an abstract type with a small, well-known family of concrete subtypes. Knowing the handful you will meet most often tells you immediately what a caller can do about a failure.
| Type | Raised when | Caller's realistic response |
|---|---|---|
ErrorException | error("...") is called | Read the message; usually a bug or bad input |
ArgumentError | An argument fails a documented rule | Validate input and retry, or report it |
DomainError | A value is outside a function's domain | Guard the range before calling |
BoundsError | An index is out of range | Check length or use eachindex |
KeyError | A dictionary key is absent | get, get!, or haskey |
DimensionMismatch | Array shapes do not agree | Reshape or validate shapes first |
InexactError | A conversion would lose information | Convert explicitly, or use a wider type |
MethodError | No method matches the argument types | Fix the call; it is a programming error |
InterruptException | Ctrl-C or a task interrupt | Let it propagate after cleanup |
# Every one of these is an object with a type you can test
isa(ArgumentError("bad"), Exception) # true
supertype(ArgumentError) # Exception
# Trigger them and inspect what you get
try
parse(Int, "abc")
catch e
typeof(e) # ArgumentError
end
try
[1, 2, 3][10]
catch e
typeof(e) # BoundsError
e.a # the index that failed
end
try
Dict(:a => 1)[:b]
catch e
typeof(e) # KeyError
e.key # :b
end
Exception fields are part of the public interface: BoundsError.a holds the index, KeyError.key the missing key, DomainError.val the offending value. Typed handling reads those fields instead of parsing the message.
Reading a StackTrace
An unhandled exception prints a message plus a stack trace: the chain of calls that led to the failure, most recent first. Reading the first few frames is usually enough to locate the bug.
function outer(x)
middle(x)
end
function middle(x)
inner(x)
end
function inner(x)
sqrt(x) # a DomainError for negative input
end
outer(-1)
# ERROR: DomainError with -1.0:
# sqrt will only return a complex result if called with a complex argument.
# Stacktrace:
# [1] sqrt(x::Int64) @ Base.Math ./math.jl:...
# [2] inner(x::Int64) @ Main ./REPL[3]:1
# [3] middle(x::Int64) @ Main ./REPL[2]:1
# [4] outer(x::Int64) @ Main ./REPL[1]:1
# [5] top-level scope @ REPL:1
# Programmatic access to the same information
try
outer(-1)
catch e
bt = catch_backtrace() # the stack trace object
showerror(stdout, e, bt) # the same rendering the REPL prints
end
Frame [1] is where the error was raised, and each following frame is its caller. When a trace confuses you, find the first frame that names your own file — that is where your code met the failing call.
What Throws in Practice
Most exceptions in real programs come from a small set of operations: parsing text, indexing a container, converting a number, opening a file, and calling a method that does not exist. Knowing those sources makes both prevention and handling easier.
parse(Int, "3.5") # ArgumentError
sqrt(-1.0) # DomainError
[1, 2, 3][0] # BoundsError
Dict(:a => 1)[:b] # KeyError
Int(3.7) # InexactError
Int8(300) # InexactError
reshape([1, 2, 3], 2, 2) # DimensionMismatch
open("does_not_exist") # SystemError
undefined_function(1) # UndefVarError at top level; MethodError inside a module
# A MethodError is the most common exception during development
[1, 2, 3] + 1 # MethodError — use .+ for element-wise addition
[1, 2, 3] .+ 1 # [2, 3, 4]
Notice how many of these are preventable with an ordinary check: haskey before indexing, tryparse before parsing, eachindex instead of a hand-written range. Chapter 5 gathers those habits into a validation strategy.
Throwing Exceptions
Raising an exception is a deliberate act with a public consequence: every caller must decide what to do about it. The type you choose and the message you write are the interface of that decision.
error, throw, and @assert
Three mechanisms exist and each has a clear purpose. error raises an ErrorException for unrecoverable situations, throw raises a value of your own type, and @assert states an invariant that should never be false.
# error: a plain ErrorException, best for "this cannot happen"
function days_in(month::Int)
month in 1:12 || error("month must be 1..12, got $month")
30 # simplified on purpose
end
days_in(13) # ERROR: month must be 1..12, got 13
# throw: any value, usually a typed exception
throw(ArgumentError("temperature below absolute zero"))
throw("a string is allowed, but poor practice") # legal, discouraged
# @assert: an invariant. Assertions may be disabled at higher optimisation
# levels, so use an explicit exception when the check must always run.
function ratio(a::Int, b::Int)
@assert b != 0 "denominator must not be zero"
a / b
end
ratio(1, 0) # ERROR: AssertionError: denominator must not be zero
# The message is attached and readable at runtime
try
days_in(0)
catch e
e isa ErrorException # true
e.msg # "month must be 1..12, got 0"
end
Use @assert for conditions that indicate a bug in your own code and an explicit exception for conditions that arrive from outside. Assertions are also self-documenting: they state what the function assumes about its inputs.
Defining Your Own Exception
A custom exception type lets callers catch your failure precisely, without matching message strings. Define a struct under Exception, add a showerror method so the message is helpful, then throw it.
struct ValidationError <: Exception
field::Symbol
reason::String
end
# A readable rendering, used by the REPL and by showerror
function Base.showerror(io::IO, e::ValidationError)
print(io, "ValidationError: field ", e.field, " — ", e.reason)
end
function validate_score(score)
score isa Real || throw(ValidationError(:score, "must be a number"))
0 <= score <= 100 || throw(ValidationError(:score, "must be within 0..100"))
score
end
validate_score(85) # 85
validate_score(120)
# ERROR: ValidationError: field score — must be within 0..100
# Callers can catch exactly this failure and inspect its fields
try
validate_score(200)
catch e
if e isa ValidationError
"$(e.field): $(e.reason)" # "score: must be within 0..100"
else
rethrow() # never swallow what you did not expect
end
end
The rethrow() in the else branch is the pattern that makes custom exceptions safe: handle what you understand and pass everything else up unchanged.
Choosing the Right Standard Type
Before defining a new type, check whether a standard one already expresses your failure. Reusing the standard vocabulary lets callers write one handler for many functions.
check_range(x, lo, hi) = lo <= x <= hi || throw(ArgumentError("$x not in $lo..$hi"))
check_range(5, 1, 10) # 5
check_range(20, 1, 10) # ERROR: ArgumentError
check_positive(x) = x > 0 || throw(DomainError(x, "must be positive"))
check_positive(-1) # ERROR: DomainError with -1
check_shapes(a, b) = size(a) == size(b) || throw(DimensionMismatch("$(size(a)) != $(size(b))"))
check_shapes(zeros(2, 2), zeros(2, 3)) # ERROR: DimensionMismatch
# A rule set: pick by what the caller can do about the failure
#
# Bad value from a user or a file -> ArgumentError
# Value outside a mathematical domain -> DomainError
# Shapes that cannot be combined -> DimensionMismatch
# A whole category of failure -> your own exception type
Rule of thumb: if the condition reads as "this argument is wrong", use ArgumentError; if it reads as "this value is outside the domain of the operation", use DomainError. Reserve custom types for categories of failure that callers will handle repeatedly.
Catching and Recovering
try/catch is the only way to handle an exception in Julia, and finally is the only construct that guarantees cleanup. The diagram below shows the three paths through the block.
finally always runs.The try/catch Block
The try block contains the code that may fail. If anything inside raises, control jumps to catch, where the exception is bound to a variable you name. Execution then continues after the block — unless you rethrow.
function safe_reciprocal(x)
try
return 1 / x
catch e
@warn "reciprocal failed" exception = (e, catch_backtrace())
return NaN
end
end
safe_reciprocal(4) # 0.25
safe_reciprocal(0) # Inf — no exception; 1/0 is legal in floating point
# A genuine failure inside the block
function parse_or_zero(s)
try
parse(Int, s)
catch e
e isa ArgumentError || rethrow() # handle only what we expect
0
end
end
parse_or_zero("42") # 42
parse_or_zero("forty-two") # 0
# The catch block sees the exception as a value
try
error("boom")
catch e
typeof(e) # ErrorException
e.msg # "boom"
end
Two details matter here: a floating-point division by zero is not an exception, and a bare catch should almost always rethrow what it cannot handle. Chapter 4 develops the second point into a rule.
Catching Specific Types
Julia's catch has no pattern-matching clause for types, so the idiom is a bare catch e followed by an isa test and a rethrow() for the rest. Wrapping that in a helper keeps the pattern readable when you handle several types.
function describe_failure(f)
try
f()
"no failure"
catch e
if e isa DomainError
"domain problem with value $(e.val)"
elseif e isa BoundsError
"index $(e.a) out of range"
elseif e isa KeyError
"no such key $(e.key)"
else
rethrow() # anything unexpected keeps travelling up
end
end
end
describe_failure(() -> sqrt(-1.0)) # "domain problem with value -1.0"
describe_failure(() -> [1, 2][9]) # "index 9 out of range"
describe_failure(() -> Dict(:a => 1)[:z])# "no such key :z"
describe_failure(() -> error("other")) # rethrown to the caller
# A reusable helper keeps the if/elseif chain out of the call sites
function catch_only(f::F, T::Type{E}) where {F, E}
try
f()
catch e
e isa T || rethrow()
e
end
end
catch_only(() -> sqrt(-1.0), DomainError) # DomainError with -1.0
try
catch_only(() -> error("boom"), DomainError)
catch e
e.msg # "boom" — not swallowed
end
That helper is the shape the standard library and packages use repeatedly: run a thunk, catch exactly one type, rethrow everything else. It makes the intent of a handler visible in one line.
finally and Cleanup
finally runs whether the block succeeded, failed, or returned early. That makes it the place for releasing resources: closing files, restoring a global setting, unlocking a mutex.
# Cleanup that must happen on every path
function read_first_line(path)
io = open(path, "r")
try
readline(io)
finally
close(io) # runs even if readline throws
end
end
# A return inside try does not skip finally
function early_return()
try
return "from try"
finally
println("finally ran")
end
end
early_return() # prints "finally ran", returns "from try"
# finally also runs when the exception is not caught here
function failing()
try
error("boom")
finally
println("cleaning up")
end
end
try
failing() # prints "cleaning up", then propagates
catch e
e.msg # "boom"
end
# A global flag restored no matter what
const STATE = Ref(false)
function with_flag(f)
STATE[] = true
try
f()
finally
STATE[] = false
end
end
with_flag(() -> STATE[]) # true inside
STATE[] # false afterwards
The rule is simple: any resource acquired before the try must be released in a finally. Chapter 5 shows the shorter do-block form that the standard library provides for exactly this pattern.
Rethrowing and Adding Context
Catching an exception is not the same as deciding what to do about it. The middle layer of an application usually knows something the low-level code does not, and the job of that layer is to add context without destroying the original failure.
rethrow and Propagation
rethrow() continues the current exception from the point of the catch block, preserving the original stack trace. throw(e) would start a fresh exception with a new trace, which hides where the problem really came from.
function validate(x)
x > 0 || throw(ArgumentError("x must be positive, got $x"))
x
end
function level_two(x)
try
validate(x)
catch e
@info "level_two saw a failure" typeof(e)
rethrow() # propagate unchanged, original trace intact
end
end
try
level_two(-5)
catch e
e isa ArgumentError # true — the original type survived
e.msg # "x must be positive, got -5"
end
# throw(e) in a catch block would replace the trace:
function loses_context(x)
try
validate(x)
catch e
throw(e) # same exception, NEW stack trace — avoid
end
end
Use rethrow() when you only want to observe or log, and a wrapped exception when you genuinely have more to say. Never use throw(e) inside a catch block.
Wrapping with Context
When a failure needs explanation, wrap it: define or reuse an exception type that carries the original as a field. Callers then see a meaningful type at your API boundary while the cause remains inspectable.
struct LoadError2 <: Exception
source::String
cause::Any # usually the caught exception
end
function Base.showerror(io::IO, e::LoadError2)
print(io, "LoadError2: failed to load ", e.source, " (", typeof(e.cause), ")")
end
function load_config(path)
try
read(path, String)
catch e
throw(LoadError2(path, e)) # a new, more informative failure
end
end
try
load_config("missing.toml")
catch e
e isa LoadError2 # true — the boundary type
e.source # "missing.toml"
e.cause isa SystemError # true — the original cause is kept
end
Two rules keep wrapping honest: keep the cause in a field so nothing is hidden, and wrap only at boundaries where your layer adds real meaning — a file loader, a network client, a plugin host.
When to Catch — and When Not To
Most try blocks in production code exist to protect a boundary: user input, the network, the filesystem, a plugin. Inside your own code, exceptions should propagate.
| Situation | Catch? | Why |
|---|---|---|
| Parsing a file the user chose | Yes | Bad input is expected; report it clearly |
| Network or database call | Yes | Failure is normal and retryable |
| Your own arithmetic or indexing bug | No | Fixing the bug is the correct response |
| Any exception, "just in case" | No | You will hide the failure you needed to see |
| Cleanup around a resource | Use finally | You want the cleanup, not to suppress the error |
| Converting a failure into a value | Yes, narrowly | Return nothing or a result type at that boundary |
# Boundary: failures are expected and converted to a value
function read_number(path)
try
parse(Int, strip(read(path, String)))
catch e
e isa Union{ArgumentError, SystemError} || rethrow()
nothing # the caller decides what "no number" means
end
end
read_number("missing.txt") # nothing
# Interior code: no try at all — the failure belongs to the caller
half(x::Real) = x / 2
# The decision is about who can fix the problem, not about where it happened
The question to ask before writing catch is "what will this code do differently because of the failure?" If the answer is "nothing", remove the try and let the exception travel.
Cleanup and Alternatives
Exceptions answer "something went wrong". Many situations are better expressed as an ordinary value, and Julia's standard library is full of functions designed for exactly that. Using them keeps failure handling local and keeps the exception table for real surprises.
The do Block
Functions that need cleanup accept a function as their first argument, and the do syntax turns your block into that argument. The library then owns the try/finally, so files and streams close even when your code throws.
# Explicit cleanup, as seen in the previous chapter
function counted_lines_v1(path)
io = open(path, "r")
try
count(_ -> true, eachline(io))
finally
close(io)
end
end
# The same thing with a do block — no try, no finally, nothing to forget
function counted_lines_v2(path)
open(path, "r") do io
count(_ -> true, eachline(io))
end
end
# A do block is just a function passed as the first argument
open("data.txt", "w") do io
write(io, "hello\n")
end # the file is closed here, guaranteed
# The pattern generalises to any resource the library manages
open("data.txt", "r") do io
readline(io) # "hello"
end
# And to your own helpers
function with_debug_on(f)
old = get(ENV, "JULIA_DEBUG", "")
ENV["JULIA_DEBUG"] = "Main"
try
f()
finally
ENV["JULIA_DEBUG"] = old
end
end
with_debug_on() do
:work # runs with the temporary setting
end
Prefer the do form whenever the standard library offers it: fewer lines, no chance of forgetting the finally, and the cleanup logic lives in one tested place.
Guard Functions Instead of Exceptions
Many operations that can fail also have a variant that reports failure as a value. Choosing those variants turns a potential exception into a branch you can read.
| Throwing version | Value-returning version | Value on failure |
|---|---|---|
parse(Int, s) | tryparse(Int, s) | nothing |
d[k] | get(d, k, default) | Your default |
d[k] when missing | get!(d, k, default) | Inserts the default |
findfirst with no match | findfirst already returns nothing | nothing |
only([]) | isempty(xs) ? default : only(xs) | Your default |
| Integer division by zero | Check first, then divide | An explicit branch |
# Reporting failure as a value instead of an exception
score(s) = something(tryparse(Int, s), 0)
score("42") # 42
score("oops") # 0 — the exception table is not involved
# Missing keys, three intents, three functions
d = Dict("a" => 1)
get(d, "b", 0) # 0 — do not change d
haskey(d, "b") # false — ask first
get!(d, "b", 0) # 0 — and now d["b"] exists
# findfirst already returns nothing, so a branch replaces a rescue
r = findfirst(isequal(9), [1, 2, 3])
r === nothing ? "absent" : "at $(first(r))" # "absent"
# Guarding a domain before the call keeps DomainError out of the picture
safe_sqrt(x) = x < 0 ? nothing : sqrt(x)
safe_sqrt(-4) # nothing
safe_sqrt(4) # 2.0
# Chaining optional values reads well with something and coalesce
a = tryparse(Int, "7")
b = tryparse(Int, "x")
something(a, 0) + something(b, 0) # 7
The rule: reserve exceptions for failures a caller cannot reasonably predict, and use value-returning functions for the predictable ones. That keeps handlers few, and makes the remaining try blocks meaningful.
Interrupts and Exit Hooks
Ctrl-C delivers an InterruptException, and a program that catches everything will swallow that too. Exit hooks and finalisers are the right tools for last-moment work.
# Never swallow an interrupt in a broad handler
try
sleep(10)
catch e
e isa InterruptException && rethrow() # let the user stop the program
@warn "recovered from a normal failure"
end
# Work that must happen when the process ends
atexit() do
println("cleaning up before exit")
end
# finalizer attaches a cleanup callback to an object
mutable struct Session
id::Int
end
function Session()
s = Session(rand(1:1000))
finalizer(x -> println("closing session ", x.id), s)
s
end
Session() # the finaliser runs when the object is collected
Finalisers are for releasing resources, not for application logic: they run at unpredictable times. Relying on them for correctness — rather than as a safety net — is a common source of data corruption that only appears in production.
Common Pitfalls
Exception handling has a short list of classic mistakes. Each is easy to write, easy to miss in review, and expensive in production.
The Bare catch
A catch without a type test swallows every failure, including the ones that mean your program is broken. The symptom appears later, far from the cause, as a wrong result rather than an error.
# Bad: hides bugs, interrupts, and typos alike
function parse_all_bad(lines)
out = Int[]
for line in lines
try
push!(out, parse(Int, line))
catch
# nothing: a typo in push! would be invisible
end
end
out
end
# Good: state exactly what is expected, and rethrow the rest
function parse_all_good(lines)
out = Int[]
for line in lines
try
push!(out, parse(Int, line))
catch e
e isa ArgumentError || rethrow() # only "not a number" is tolerated
end
end
out
end
parse_all_good(["1", "2", "x", "4"]) # [1, 2, 4]
parse_all_bad(["1", "2", "x", "4"]) # [1, 2, 4] — same result, hidden risks
The rule for review: if a catch block does not mention a type, it needs a written justification in a comment. In practice that justification is almost never good enough.
Exceptions as Control Flow
Using exceptions to implement ordinary branching is both slow and unclear: every throw unwinds the stack, and a reader has to hold two paths in mind where one would do.
# Using an exception to signal a normal condition
function first_even_bad(xs)
try
for x in xs
x % 2 == 0 && throw(x) # abuse: throwing to return early
end
throw(:none)
catch e
e isa Integer ? e : nothing
end
end
# The same intent, expressed as an ordinary search
function first_even_good(xs)
for x in xs
x % 2 == 0 && return x
end
nothing
end
first_even_good([1, 3, 4, 5]) # 4
first_even_good([1, 3, 5]) # nothing
# When you truly want to stop a long computation, an exception is fine —
# but it should mean "something is wrong", not "here is the next value".
struct StopIteration2 <: Exception end
Reserve exceptions for abnormal conditions. If the condition is part of the normal contract of the function, it belongs in the return type — nothing, a Union, or a dedicated result struct.
Losing the Message or the Cause
An exception that is caught and reported without its message leaves the next developer with nothing to work from. Log the exception object, not a generic sentence, and keep the cause when you wrap.
# Bad: the message, the type, and the trace are all gone
try
risky()
catch e
@warn "something failed"
end
# Good: the exception travels with the log record
try
risky()
catch e
@warn "risky() failed" exception = (e, catch_backtrace())
rethrow()
end
# Also good when the failure is expected and handled: report and continue
function risky()
throw(ArgumentError("expected value was missing"))
end
try
risky()
catch e
e isa ArgumentError || rethrow()
@info "handled, continuing" reason = e.msg
end
# When wrapping, keep the cause — never build a new exception from a string only
struct WrappedFailure <: Exception
message::String
cause::Exception
end
Base.showerror(io::IO, e::WrappedFailure) =
print(io, "WrappedFailure: ", e.message, " caused by ", typeof(e.cause))
The single most useful habit is passing (e, catch_backtrace()) to the logging macro. The @warn and @error macros know how to render that pair as a full diagnostic with a trace.
Union Returns and Type Stability
A function that returns a value or nothing has a Union return type, which is fine at a boundary and expensive in a hot loop. Julia handles small unions well, but a union propagating through a computation forces boxed variables.
# A Union return is idiomatic for "may be absent"
function try_div(a, b)
b == 0 ? nothing : a / b
end
try_div(6, 3) # 2.0
try_div(6, 0) # nothing
# Type-stable use at a boundary: handle the union once, then work with a concrete type
function average(values)
total = 0.0
n = 0
for v in values
d = try_div(v, 2)
d === nothing && continue
total += d
n += 1
end
n == 0 ? nothing : total / n
end
average([2.0, 4.0]) # 1.5
average([]) # nothing
# Inside a hot loop, prefer a concrete default to a Union
safe_div(a, b, default = 0.0) = b == 0 ? default : a / b
# The trade-off: `nothing` is clearer; a default is faster. Choose per boundary.
The practical guidance: let Union{T, Nothing} live at your API edge, then narrow it to a concrete type before the performance-critical section. something and explicit branches are how you narrow it.
Exception value with a type, and the type tells a caller what can be done about it. Throw with error for unrecoverable conditions, throw for your own types, and @assert for invariants. Handle with try/catch, test the type with isa, and always rethrow() what you do not understand. Use finally or a do block for cleanup, rethrow() (never throw(e)) to propagate, and a wrapping exception when your layer adds context. Prefer value-returning guards such as tryparse, get, and haskey for predictable failures, and keep try blocks at real boundaries.
This closes the intermediate phase: you can define types, attach behaviour, generalise over types, organise code into packages, and manage failure. Next: Metaprogramming & Macros opens the phase on advanced programming, where code writes code.