The Condition System: Beyond Simple Exceptions
Common Lisp does not have a bare exception mechanism; it has a condition system that separates the act of signaling a problem from the act of deciding how to respond to it. When code calls (error "disk full") or (signal 'my-condition), the runtime walks up the stack looking for a handler, but crucially it does this without unwinding the stack first. That means a handler can inspect the exact state where the problem occurred and choose to resume execution there, not just log and abandon the computation the way a thrown exception in most languages forces you to.
Cricket analogy: It's like a third umpire review: when a decision is signalled as uncertain, play does not immediately stop and restart from scratch — the on-field umpire, TV umpire, and captain all get to look at the same replay before choosing how to proceed, rather than just overturning blindly.
Signaling Conditions: error, warn, and define-condition
Conditions are ordinary CLOS classes defined with define-condition, typically subclassing error, warning, or condition itself. (error 'my-condition :slot value) both creates the instance and signals it, and if no handler intervenes, it drops into the debugger. warn signals a condition of type warning and, if unhandled, prints a message and continues — it never enters the debugger by default. This three-way split (error, warning, plain signal) lets a library author express severity precisely: a malformed but recoverable input can warn, while a corrupted file handle should error.
Cricket analogy: A no-ball called by the umpire is like warn — play continues with a free hit awarded — whereas a run-out appeal that ends an innings is like error, since it must be adjudicated before the game state can move on.
handler-case vs handler-bind
handler-case is the convenient, exception-like form: it unwinds the stack back to the handler-case form before running its clause, so it's safe and simple but you lose access to the original dynamic state. handler-bind, by contrast, runs its handler function in the exact dynamic environment where the condition was signalled, without unwinding — this is what makes restart invocation possible, because the stack frames that established the restarts are still live. As a rule of thumb, reach for handler-case when you just want to catch-and-recover like a normal exception, and handler-bind when you need to inspect state or invoke a restart before deciding to unwind.
Cricket analogy: handler-case is like a match referee reviewing the incident only after play has stopped and everyone has walked off, while handler-bind is like reviewing it live on the field with the fielders still in position, able to replay the exact moment.
(define-condition disk-full-error (error)
((path :initarg :path :reader disk-full-path))
(:report (lambda (c stream)
(format stream "Disk full while writing to ~a" (disk-full-path c)))))
(defun write-log-entry (path entry)
(restart-case
(if (disk-space-low-p path)
(error 'disk-full-error :path path)
(append-to-file path entry))
(use-alternate-path (new-path)
:report "Write to a different path instead"
(write-log-entry new-path entry))
(skip-entry ()
:report "Skip this log entry"
nil)))
(handler-bind ((disk-full-error
(lambda (c)
(declare (ignore c))
(invoke-restart 'use-alternate-path "/tmp/overflow.log"))))
(write-log-entry "/var/log/app.log" "startup complete"))Restarts: Interactive Recovery Points
A restart, established with restart-case or restart-bind, is a named recovery strategy that a signalling function offers to whoever ends up handling its condition — it does not decide how to recover, only what the available options are. Callers invoke one with invoke-restart, or use the built-ins use-value and store-value for the common pattern of substituting a bad value and retrying. This inversion of control is the condition system's signature idea: the low-level code that detects the problem publishes possible fixes, while the high-level code (or even a human at the interactive debugger) picks which one applies.
Cricket analogy: It's like a captain offering the bowler several field-placement options after a boundary is hit — deep cover, third man, or a change of bowler — and the coaching staff upstairs decides which one to invoke based on the match situation.
The debugger you see when an unhandled error occurs in a Lisp REPL is not a crash screen — it is a live handler-bind at the top of the stack that lists every restart currently available, letting you fix data and resume the computation interactively rather than restarting the whole program.
handler-case always unwinds the stack before its clause body runs, so restart-case options established inside the protected form are gone by the time the handler executes — you cannot call invoke-restart from a handler-case clause the way you can from handler-bind.
- Common Lisp's condition system separates signaling a problem from deciding how to handle it, unlike try/catch which conflates the two.
- define-condition creates CLOS-based condition classes, typically subclassing error, warning, or condition.
- error signals and enters the debugger if unhandled; warn signals and simply continues after printing a message.
- handler-case unwinds the stack before running its clause, offering simple, safe, exception-like recovery.
- handler-bind runs in the original dynamic environment without unwinding, which is required to invoke restarts.
- restart-case publishes named recovery strategies at the point of failure without deciding which one to use.
- invoke-restart, use-value, and store-value let a handler (or a human in the debugger) choose how execution resumes.
Practice what you learned
1. What is the key difference between handler-case and handler-bind?
2. Why can't you call invoke-restart from inside a handler-case clause targeting a restart established inside the protected form?
3. What happens by default when an unhandled condition signalled with warn occurs?
4. What does restart-case actually do?
5. Which built-in restart is designed for substituting a corrected value and continuing?
Was this page helpful?
You May Also Like
File I/O in LISP
How Common Lisp reads and writes files using streams, with-open-file, and reader/printer control for both text and binary data.
Quicklisp and Package Management
How Quicklisp and ASDF work together to define, load, and distribute Common Lisp systems and their dependencies.
Scheme vs Common Lisp
The core philosophical and technical differences between Scheme's minimalist design and Common Lisp's large, industrial standard.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics