Guile exceptions

Andreas Hohmann November 08, 2025 #guile #scheme #errors #continuation

Error 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:

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: 101

Conclusion

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.