Exceptions

An exception is a value that travels up the call stack until somebody handles it. Julia exceptions are ordinary objects with types, which means you can match them precisely, attach context, and define your own — but also that catching everything is a mistake you can make in one line.

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.

TypeRaised whenCaller's realistic response
ErrorExceptionerror("...") is calledRead the message; usually a bug or bad input
ArgumentErrorAn argument fails a documented ruleValidate input and retry, or report it
DomainErrorA value is outside a function's domainGuard the range before calling
BoundsErrorAn index is out of rangeCheck length or use eachindex
KeyErrorA dictionary key is absentget, get!, or haskey
DimensionMismatchArray shapes do not agreeReshape or validate shapes first
InexactErrorA conversion would lose informationConvert explicitly, or use a wider type
MethodErrorNo method matches the argument typesFix the call; it is a programming error
InterruptExceptionCtrl-C or a task interruptLet 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.

Flow through a try block: with no error it continues after the block; a raised exception enters catch, which either handles it and continues or rethrows it out of the call; the finally block runs on every path.
Two exits, one guaranteed cleanup: 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.

SituationCatch?Why
Parsing a file the user choseYesBad input is expected; report it clearly
Network or database callYesFailure is normal and retryable
Your own arithmetic or indexing bugNoFixing the bug is the correct response
Any exception, "just in case"NoYou will hide the failure you needed to see
Cleanup around a resourceUse finallyYou want the cleanup, not to suppress the error
Converting a failure into a valueYes, narrowlyReturn 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 versionValue-returning versionValue on failure
parse(Int, s)tryparse(Int, s)nothing
d[k]get(d, k, default)Your default
d[k] when missingget!(d, k, default)Inserts the default
findfirst with no matchfindfirst already returns nothingnothing
only([])isempty(xs) ? default : only(xs)Your default
Integer division by zeroCheck first, then divideAn 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.

Summary. Every failure in Julia is an 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.