Guile exceptions
Andreas Hohmann November 08, 2025 #guile #scheme #errors #continuationError handling dominates most production programss. While we like to focus on the sunny day scenarios and domain models, in reality most of production code detects and handles the "alternate courses" caused by the myriad of error conditions.
The quality of error handling and error messages often makes the difference between an enjoyable and a barely usable system. Good error messages make happy users and accelerate adoption. Rust's excellent compiler error messages, for example, play a large role in its success.
Considering its importance, it's surprising that error handling is not a settled issue yet. Programming languages apply different paradigms and often have not landed on a generally accepted solution.
What's needed to detect, signal, and handle errors nicely? We obviously need a way to capture the root cause of a problem with the key data leading to the error. We would also like to add context information as an error bubbles up the call stack. However, we don't want overly verbose or duplicate information such as nested exceptions with their call stacks (Java's stack trace hell).
The syntax for raising and handling exception should be concise so that the error handling code does not overwhelm the main functionality. We would like to handle the errors in a few central places with different levels of specificity, for example, all IO errors or just file-not-found errors.
Ideally, it should also be possible to handle minor errors gracefully by continuing where the issue occurred, possible with a new value replacing the failed expression. On the other hand, it should be close to impossible to ignore errors. Handling them gracefully must be a deliberate choice.
This post explores how Guile's exception system addresses these questions.
Legacy errors: keys and arguments
Given its long history, it's no surprise that Guile has more than one
error handling API. For the longest time (until 2019), an error was a
combination of a symbol and a list of arguments. These "legacy" errors
are raised with the throw form which takes the key and arguments:
scheme@(guile-user)> (throw 'foo "bar" 10)
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
Throw to key `foo' with args `("bar" 10)'.
Entering a new prompt. Type `,bt' for a backtrace or `,q' to continue.
In this example, the error key is the symbol 'foo and the arguments
are the string "bar" and the number 10. The arguments could be of
any type.
Catching errors
We can catch these key-args errors with the catch form which looks
almost like a catch statement in other languages:
scheme@(guile-user)>
(catch 'foo
(lambda ()
(throw 'foo "bar" 10))
(lambda (key . args) (cons key args)))
$9 = (foo "bar" 10)
The catch form takes the key of the error to be handled, the
argument-less procedure ("thunk") to be executed, and the handler
function. If we want to catch multiple keys, we can use #t as the
catch-all key and check the key explicitly, for example, using a
case form. The following example catches the 'foo and 'bar keys
and rethrows all other errors:
scheme@(guile-user)>
(define (handle-foo-bar-errors thunk)
(catch #t
thunk
(lambda (key . args)
(case key
((foo bar)
(format #f "foo or bar error: ~a" args))
(else (throw key args))))))
scheme@(guile-user)> (handle-foo-bar-errors (lambda () (throw 'foo 1 2 3)))
$10 = "foo or bar error: (1 2 3)"
scheme@(guile-user)> (handle-foo-bar-errors (lambda () (throw 'bar 1 2 3)))
$11 = "foo or bar error: (1 2 3)"
scheme@(guile-user)> (handle-foo-bar-errors (lambda () (throw 'baz 1 2 3)))
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
Throw to key `baz' with args `((1 2 3))'.
This legacy error model is also used by most built-in error such as division by zero:
scheme@(guile-user)> (/ 1 0)
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
Throw to key `numerical-overflow' with args `("divide" "Numerical overflow" #f #f)'.
Entering a new prompt. Type `,bt' for a backtrace or `,q' to continue.
scheme@(guile-user) [1]> ,q
scheme@(guile-user)>
(catch 'numerical-overflow
(lambda () (/ 1 0))
(lambda (key . args)
(format #f "caught numerical overflow: ~a" args)))
$12 = "caught numerical overflow: (divide Numerical overflow #f #f)"
Instead of handling the error, we could enrich the error by adding
more context to the arguments. Let's first wrap the division in a
function compute-something with print statements before and after
the computation. The function also continues to use the value if the
division does not fail.
(define (compute-something x)
(format #t "before division\n")
(let ((y (/ 1 (- 10 x))))
(format #t "after division\n")
(* 10 y)))
Here is the behavior for a good and a bad input before any exception handling:
scheme@(guile-user)> (compute-something 20)
before division
after division
$111 = -1
scheme@(guile-user)> (compute-something 10)
before division
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
Throw to key `numerical-overflow' with args `("divide" "Numerical overflow" #f #f)'.
Next, we define a function that calls the computation, catches the exception, and rethrows it with two additional arguments.
(define (compute-and-catch x)
(catch 'numerical-overflow
(lambda () (compute-something x))
(lambda (key . args)
(apply throw key (append args (list "overflow in compute-something" x))))))
We get the same key as without the catch form, but with the extra context arguments:
scheme@(guile-user)> (compute-and-catch 20)
before division
after division
$110 = -1
scheme@(guile-user)> (compute-and-catch 10)
before division
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
Throw to key `numerical-overflow' with args `("divide" "Numerical overflow" #f #f "overflow in compute-something" 10)'.
The throw/catch model with keys and arguments is straightforward to use, but does not satisfy all items on our wish list. We can add context as additional arguments, but there is no common way to separate and handle these different pieces of information. Furthermore, the error keys are just symbols in a flat namespace. There is no hierarchy that we could use to handle errors on different levels of specificity.
The legacy error system also does not support the nice-to-have feature
of continuing in case of minor problems. We can call catch with an
additional pre-unwind-handler, but its purpose is to capture data
before unwinding the stack and not to prevent the unwinding of the
stack altogether.
scheme@(guile-user)>
(catch 'numerical-overflow
(lambda () (+ 1 (/ 1 0)))
(lambda (key . args) 10)
(lambda (key . args) (format #t "pre-unwind: ~a ~a~%" key args)))
pre-unwind: numerical-overflow (divide Numerical overflow #f #f)
$13 = 10
We also don't have access to the continuation at the place where the error was raised.
Composable, hierarchical exceptions
Guile's new exception system (which is apart from naming equivalent to scheme's condition system defined in the SRFI-35) attempts to satisfy all requirements on our wish list. Naturally, this makes it more complicated. However, the old simpler system can still be used. The latest implementation is built on top of the new system so that both can seamlessly interact. The new exception system combines three interesting ideas:
- a hierarchy of exception types for multi-level exception handling
- compound exceptions for combining multiple aspects of an error
- raising and handling exceptions with or without unwinding the stack
Let's start by investigating the composable, hierarchical structure of
the new exception objects. Guile's new exceptions are ice-9 records
organized in a hierarchy with the &exception type at its root. By
convention, the exception type names start with an ampersand such as
&message and &warning. We can explore the exception type hierarchy
with the record API:
scheme@(guile-user)> &message
$47 = #<record-type &message>
scheme@(guile-user)> (record-type-parent &message)
$48 = #<record-type &exception>
scheme@(guile-user)> (record-type-parent &warning)
$49 = #<record-type &exception>
scheme@(guile-user)> (record-type-fields &message)
$50 = (message)
scheme@(guile-user)> (record-type-fields &warning)
$51 = ()
We see that &message and &warning are children of the root
&exception. A &message record has a single message field, and
a &warning no fields at all. It is one of the marker types as we
will see below.
We can add our own exceptions to this hierarchy using
define-exception-type which works very similar to define-record-type.
scheme@(guile-user)>
(define-exception-type &out-of-range &exception
make-out-of-range
out-of-range?
(low out-of-range-low)
(high out-of-range-high)
(value out-of-range-value))
scheme@(guile-user)> (record-type-fields &out-of-range)
$53 = (low high value)
scheme@(guile-user)> (define e (make-out-of-range 10 0 5))
scheme@(guile-user)> e
$54 = #<&out-of-range low: 10 high: 0 value: 5>
scheme@(guile-user)> (exception? e)
$55 = #t
scheme@(guile-user)> (out-of-range? e)
$56 = #t
scheme@(guile-user)> (out-of-range-value e)
$57 = 5
What may be surprising at first is that make-exception does not
return an &exception record but a &compound-exception which is not
even derived from &exception.
scheme@(guile-user)> (make-exception)
$58 = #<&compound-exception components: ()>
scheme@(guile-user)> (record-type-fields &compound-exception)
$59 = (components)
scheme@(guile-user)> (record-type-parent &compound-exception)
$60 = #f
scheme@(guile-user)> (exception? (make-exception))
$62 = #t
Exception therefore come in two flavors, simple exceptions and compound exceptions. The simple exceptions are part of the exception hierarchy, and compound exceptions wrap a list of simple exceptions.
make-exception takes a list of exceptions that are flattened to
become the components of the result compound exception. The components
can be extracted from a compound exception with the
simple-exceptions function which conveniently works for simple
exceptions as well by wrapping a simple exception in a list:
scheme@(guile-user)> (define e (make-exception (make-exception-with-message "foo") (make-warning)))
scheme@(guile-user)> e
$66 = #<&compound-exception components: (#<&message message: "foo"> #<&warning>)>
scheme@(guile-user)> (simple-exceptions e)
$67 = (#<&message message: "foo"> #<&warning>)
scheme@(guile-user)> (simple-exceptions (make-exception-with-message "foo"))
$68 = (#<&message message: "foo">)
On the other hand, the functions defined for the individual exception types work for compound exceptions as well by looking for the first simple exception of the appropriate type:
scheme@(guile-user)> (exception-message e)
$73 = "foo"
scheme@(guile-user)> (exception-with-message? e)
$74 = #t
scheme@(guile-user)> (exception-with-message? (make-exception (make-warning)))
$75 = #f
scheme@(guile-user)> (exception-message (make-exception (make-warning)))
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
object is not an exception of the right type #<&warning> #<record-type &message>Errors as exceptions
How do the old-style errors fit into this new exception model? We can
find out by looking at the exceptions created by
make-exception-from-throw which is used to construct the exceptions
corresponding to the legacy errors:
scheme@(guile-user)> (define e (make-exception-from-throw 'foo '("bar" 10)))
scheme@(guile-user)> e
$77 = #<&compound-exception components: (
#<&error>
#<&irritants irritants: ("bar" 10)>
#<&exception-with-kind-and-args kind: foo args: ("bar" 10)>
)>
scheme@(guile-user)> (simple-exceptions e)
$78 = (#<&error> #<&irritants irritants: ("bar" 10)> #<&exception-with-kind-and-args kind: foo args: ("bar" 10)>)
scheme@(guile-user)> (error? e)
$82 = #t
scheme@(guile-user)> (exception-kind e)
$79 = foo
scheme@(guile-user)> (exception-args e)
$80 = ("bar" 10)
scheme@(guile-user)> (exception-irritants e)
$81 = ("bar" 10)
A legacy error is translated to a compound exception with at least
three components. Firstly, the exception contains the marker exception
&error to mark it as an actual error, not a warning. Secondly, the
key-args data is stored in a special &exception-with-kind-and-args
exception with kind (another name for the error key) and args
fields. In addition, the arguments are also placed in the &irritants
exception. This is the new place for context data, the "irritating"
data causing the issue, of an exception.
Let's check what the exception looks like for the built-in numerical overflow error that we studied above:
scheme@(guile-user)> (define e
(catch 'numerical-overflow
(lambda () (/ 1 0))
(lambda (key . args) (make-exception-from-throw key args))))
scheme@(guile-user)> e
$84 = #<&compound-exception components: (
#<&implementation-restriction>
#<&origin origin: "divide">
#<&message message: "Numerical overflow">
#<&irritants irritants: #f>
#<&exception-with-kind-and-args kind: numerical-overflow args: ("divide" "Numerical overflow" #f #f)>
)>
scheme@(guile-user)> (exception-message e)
$85 = "Numerical overflow"
scheme@(guile-user)> (exception-origin e)
$86 = "divide"
scheme@(guile-user)> (exception-irritants e)
$87 = #f
scheme@(guile-user)> (exception-kind e)
$88 = numerical-overflow
scheme@(guile-user)> (exception-args e)
$89 = ("divide" "Numerical overflow" #f #f)
We still have the &exception-with-king-and-args containing the
error's key (kind) and arguments, but the irritants are empty and
there is no &error marker. Instead, the exception is marked as an
&implementation-restriction and contains the &origin and a
message. Guile implements custom conversions for all legacy errors,
and each conversion adds error-specific components to the resulting
compound exception.
You may be wondering what happens if the same simple exception type occurs multiple times in a compound exception. As mentioned above, the individual accessors always look for the first matching component. However, we can always look at all matching components and collect the results.
scheme@(guile-user)>
(define (simple-exceptions-field-values e predicate accessor)
(map accessor (filter predicate (simple-exceptions e))))
scheme@(guile-user)> (simple-exceptions-field-values e exception-with-message? exception-message)
$94 = ("Numerical overflow")
scheme@(guile-user)> (define e (make-exception (make-exception-with-message "foo")
(make-exception-with-message "bar")))
scheme@(guile-user)> (simple-exceptions-field-values e exception-with-message? exception-message)
$97 = ("foo" "bar")
scheme@(guile-user)> (exception-message e)
$98 = "foo"Raising exceptions
At this point we know how Guile models complex exceptions. Combining the "simple" exceptions organized in a type hierarchy with a flat compound exception is an interesting take on capturing error information. It avoids the deeply nested wrapper exceptions, adds predictable data structures, and is extensible.
Next, we want to understand how these exceptions are raised and
handled. The raising part is straightforward: We create an exception
as described above and pass it to the
raise-exception procedure:
scheme@(guile-user)> (raise-exception (make-exception-with-message "some error"))
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
ERROR:
1. &message: "some error"
This function takes a boolean #:continuable? keyword parameter that
is an early indication that we may be able to handle certain
exceptions by continuing where they are thrown. Without a
corresponding handler, this parameter does not make a difference:
scheme@(guile-user)> (raise-exception (make-exception-with-message "some error") #:continuable? #t)
ice-9/boot-9.scm:1683:16: In procedure raise-exception:
ERROR:
1. &message: "some error"Handling exceptions (with-exception-handler)
The "catch" part of the new exception system is based on the
with-exception-handler procedure. Just like the catch procedure,
with-exception-handler takes the handler and the body ("thunk") as
procedure arguments albeit in the opposite order (handler-first).
(define (compute-and-handle x)
(with-exception-handler
(lambda (e)
(cond
((eq? (exception-kind e) 'numerical-overflow)
(display "handling numerical-overflow\n")
(raise-exception
(make-exception e (make-exception-with-message "another error"))))
(else
(raise-exception e))))
(lambda () (compute-something x))))
Since not all exceptions have a key, with-exception-handler does not
have a key argument. Instead, we have to check the kind explicitly in
the handler function.
Apart from the print statement, the behavior observable on REPL is the same as the one without the exception handler because the output shows only the first message (here "Numerical overflow").
scheme@(guile-user)> (compute-and-handle 20)
before division
after division
$119 = -1
scheme@(guile-user)> (compute-and-handle 10)
before division
handling numerical-overflow
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
Throw to key `numerical-overflow' with args `("divide" "Numerical overflow" #f #f)'.
Instead of rethrowing the exception, let's now try to handle it.
(define (compute-and-handle-100 x)
(with-exception-handler
(lambda (e)
(cond
((eq? (exception-kind e) 'numerical-overflow)
(display "handling numerical-overflow\n")
100)
(else
(raise-exception e))))
(lambda () (compute-something x))))
Somewhat surprisingly this does not work. Instead of the expected new
result 100, we get a system exception of type @non-continuable.
scheme@(guile-user)> (compute-and-handle-100 10)
before division
handling numerical-overflow
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
ERROR:
1. &non-continuable
By default, with-exception-handler does not unwind the stack. The
handler is executed in the environment of the raise-exception
call. When the handler rethrows as we did in compute-and-handle, the
handler is skipped (to avoid applying the same handler
again). However, if the handler does not raise and exception and
therefore attempts to continue, Guile checks if the raise-exception
call was continuable. The error raised due to a division-by-zero error
is not continuable, leading to the observed &non-continuable exception.
To get the desired behavior we have to turn the handler into an
"unwinding handler" by setting the #:unwind? keyword argument to
true. With this value, Guile first unwinds the stack and then
evaluates the exception handler in the environment of the
with-exception-handler call.
(define (compute-and-handle-100 x)
(with-exception-handler
(lambda (e)
(cond
((eq? (exception-kind e) 'numerical-overflow)
(display "handling numerical-overflow\n")
100)
(else
(raise-exception e))))
(lambda () (compute-something x))
#:unwind? #t))
With this change, the handler behaves as expected:
scheme@(guile-user)> (compute-and-handle-100 10)
before division
handling numerical-overflow
$126 = 100
I'm not sure if the default of non-unwinding handlers was a good choice. Most exception handler I have seen are unwinding.
Exception handler shortcut: guard macro
The two lamdas with the cond-form in the exception handler are common
enough to deserve a simplifying syntax. The guard macro takes a
sequence of cond-like handler clauses (preceded by a symbol for the
exception) and the body that may raise an exception.
(define (compute-and-guard x)
(guard (e ((eqv? (exception-kind e) 'numerical-overflow)
(display "handling numerical-overflow\n")
(raise-exception (make-exception e (make-exception-with-message "another error"))))
(else
(raise-exception e)))
(compute-something x)))
Whereas the catch and with-exception-handler procedures have to
take the thunk and handler as function arguments, the guard macro
can directly embed the handler clauses and main body.
To verify the additional message, let's capture the exception object
with another guard clause.
scheme@(guile-user)> (define e (guard (e (else e)) (compute-and-guard 10)))
before division
handling numerical-overflow
scheme@(guile-user)> e
$116 = #<&compound-exception components: (
#<&implementation-restriction>
#<&origin origin: "divide">
#<&message message: "Numerical overflow">
#<&irritants irritants: #f>
#<&exception-with-kind-and-args kind: numerical-overflow args: ("divide" "Numerical overflow" #f #f)>
#<&message message: "another error">
)>
scheme@(guile-user)> (simple-exceptions-field-values e exception-with-message? exception-message)
$117 = ("Numerical overflow" "another error")
This shows the additional message added by the handler.
Continuation
Now what about the promised ability to continue a computation in case
of minor issues? We saw the #:continuable? argument of the
raise-exception function as the first ingredient. This allows us to
indicate when raising an exception that the handler may decide to
continue the program where the exception was raised, possibly with a
different value.
As a (admittedly not very useful) example, let's raise a warning if a value exceeds a certain threshold. The handler can either unwind the stack as usual or decide to only log a warning and continue.
Here is the function raising the warning. In addition to the warning marker, we use the out-of-range exception type defined above.
(define (compute-with-warning x)
(if (or (< x 0) (> x 10))
(raise-exception (make-exception (make-warning)
(make-out-of-range 0 10 x))
#:continuable? #t))
(* 10 x))
Without exception handler, we get the expected custom exception when
the argument x is not within the [0, 10] range.
scheme@(guile-user)> (compute-with-warning 10)
$124 = 100
scheme@(guile-user)> (compute-with-warning 11)
ice-9/boot-9.scm:1683:16: In procedure raise-exception:
ERROR:
1. &warning
2. &out-of-range:
low: 0
high: 10
value: 11
Note that we are raising the warning with the #:continuable
flag. This means that we can use a non-unwinding exception handler
that does not raise an exception.
(define (compute-and-log x)
(with-exception-handler
(lambda (e)
(cond
((and (warning? e) (out-of-range? e))
(format #t "handling out-of-range warning: ~a\n" e)
(let ((high (out-of-range-high e))
(value (out-of-range-value e)))
(if (> value (* 10 high))
(raise-exception e)
(begin
(format #t "value ~a not too high, continuing\n" x)
x))))
(else (raise-exception e))))
(lambda () (compute-with-warning x))))
This handler rethrows the out-of-range exception only if the value is more than ten times larger than the upper limit.
scheme@(guile-user)> (compute-and-log 10)
$127 = 100
scheme@(guile-user)> (compute-and-log 11)
handling out-of-range warning: #<&compound-exception components: (#<&warning> #<&out-of-range low: 0 high: 10 value: 11>)>
value 11 not too high, continuing
$128 = 110
scheme@(guile-user)> (compute-and-log 101)
handling out-of-range warning: #<&compound-exception components: (#<&warning> #<&out-of-range low: 0 high: 10 value: 101>)>
ice-9/boot-9.scm:1685:16: In procedure raise-exception:
ERROR:
1. &warning
2. &out-of-range:
low: 0
high: 10
value: 101Conclusion
Guile's exception system (and the equivalent SRFI-35 condition system) offers a compelling solution for signaling and handling errors. I'm particular impressed by the combination of the hierarchical exception types and the flat compound exceptions. This approach strikes a nice balance between hierarchy for generalization and composition for data.
Augmenting error with context information in additional objects is definitely superior to the common approach of wrapping errors in larger and larger objects. As an example, I could envision another message-like exception type for user-facing messages (or message data) that could be mixed into exceptions that ultimately are shown to a user.
The ability to continue a computation in case of a recoverable error is also extremely powerful, but may be almost too clever. But constrained to the appropriate use cases, it is a good tool to have.
I'm looking forward to seeing how this exception system will be adopted in applications and which patterns will prove most useful.