← Back to DevBytes

Error Handling Patterns in Clojure

Error Handling Patterns in Clojure

What Is Error Handling in Clojure?

Error handling in Clojure refers to the set of patterns, idioms, and libraries used to detect, represent, propagate, and recover from exceptional situations in functional programs. Unlike Java's checked exception mechanism, Clojure takes a more pragmatic approach—it runs on the JVM and inherits Java's exception infrastructure, but layers functional patterns on top that emphasize data-driven error representation rather than control-flow-based exception handling.

At its core, Clojure error handling revolves around these key ideas:

Why Error Handling Patterns Matter in Clojure

Effective error handling is critical for several reasons:

Core Error Handling Primitives

try/catch/finally — The JVM Foundation

Clojure's try block mirrors Java's exception handling with a Lisp syntax. You can catch specific exception types and optionally execute a finally block for cleanup.

(defn safe-divide [numerator denominator]
  (try
    (/ numerator denominator)
    (catch ArithmeticException e
      (str "Math error: " (.getMessage e)))
    (catch Exception e
      (str "General error: " (.getMessage e)))
    (finally
      (println "Division attempt completed."))))

;; Usage
(safe-divide 10 2)   ;; => 5, prints cleanup message
(safe-divide 10 0)   ;; => "Math error: Divide by zero", prints cleanup message

throwing Exceptions

Clojure provides throw to raise exceptions. You can throw Java exceptions directly or use Clojure's ExceptionInfo class via ex-info for attaching contextual data.

(defn validate-age [age]
  (if (and (integer? age) (pos? age) (< age 150))
    age
    (throw (IllegalArgumentException. "Age must be a positive integer below 150"))))

;; Using ex-info for structured exceptions
(defn validate-user [user-map]
  (if (:email user-map)
    user-map
    (throw (ex-info "User must have an email"
                    {:user user-map
                     :reason :missing-email}))))

;; Catching and extracting ex-data
(defn process-user [user-map]
  (try
    (validate-user user-map)
    (catch clojure.lang.ExceptionInfo e
      (println "Error data:" (ex-data e))
      (println "Error message:" (ex-message e))
      nil)))

Pattern 1: Exceptions as Data with ex-info

This is the most idiomatic Clojure pattern. Instead of just throwing a string message, attach a Clojure map to the exception using ex-info. Consumers can extract this map with ex-data.

(defn withdraw [account amount]
  (let [balance (:balance account)]
    (if (>= balance amount)
      (assoc account :balance (- balance amount))
      (throw (ex-info "Insufficient funds"
                      {:account-id (:id account)
                       :balance balance
                       :requested amount
                       :deficit (- amount balance)
                       :timestamp (System/currentTimeMillis)})))))

(defn process-withdrawal [account amount]
  (try
    (withdraw account amount)
    (catch clojure.lang.ExceptionInfo e
      (let [data (ex-data e)]
        (str "Failed withdrawal for account " (:account-id data)
             ": balance " (:balance data)
             ", requested " (:requested data))))))

;; Test
(process-withdrawal {:id "A123" :balance 50} 100)
;; => "Failed withdrawal for account A123: balance 50, requested 100"

The beauty of this pattern is that ex-data returns a regular Clojure map that can be destructured, logged, or passed to other functions—no opaque stack trace parsing required.

Pattern 2: Error as Value (Return-Based Errors)

Instead of throwing, functions can return error markers. Common approaches include returning {:error ...} maps, using nil for "not found," or wrapping results in a success/failure tuple.

(defn parse-int [s]
  (try
    (Integer/parseInt s)
    (catch NumberFormatException _
      {:error :invalid-number
       :input s
       :message (str "Cannot parse '" s "' as integer")})))

(defn safe-divide-v2 [n d]
  (if (zero? d)
    {:error :division-by-zero
     :numerator n
     :message "Division by zero"}
    {:result (/ n d)}))

;; Pattern matching on results
(defn handle-parse-result [result]
  (if (:error result)
    (str "Error: " (:message result))
    (str "Parsed value: " result)))

;; Usage
(handle-parse-result (parse-int "42"))   ;; => "Parsed value: 42"
(handle-parse-result (parse-int "abc"))  ;; => "Error: Cannot parse 'abc' as integer"

This pattern is common in libraries that avoid throwing for expected failure modes. It keeps the call stack clean and makes error handling explicit in the type of the return value.

Pattern 3: The Either Monad Pattern

For more sophisticated error composition, the Either pattern (borrowed from Haskell) represents computations that may fail. A result is either a Right (success) or a Left (error). This enables chaining operations that short-circuit on the first error.

(defn right [v] {:type :right :value v})
(defn left [v] {:type :left :value v})

(defn either-bind [e f]
  (if (= :right (:type e))
    (f (:value e))
    e))

(defn either-map [e f]
  (if (= :right (:type e))
    (right (f (:value e)))
    e))

;; Usage in a pipeline
(defn validate-positive [n]
  (if (pos? n)
    (right n)
    (left {:error :must-be-positive :value n})))

(defn double-even [n]
  (if (even? n)
    (right (* 2 n))
    (left {:error :must-be-even :value n})))

(defn add-ten [n]
  (right (+ 10 n)))

(defn process-number [n]
  (-> (right n)
      (either-bind validate-positive)
      (either-bind double-even)
      (either-bind add-ten)))

;; Results
(process-number 6)   ;; => {:type :right, :value 22}
(process-number -3)  ;; => {:type :left, :value {:error :must-be-positive, :value -3}}
(process-number 7)   ;; => {:type :left, :value {:error :must-be-even, :value 7}}

This pattern shines when you have multiple sequential operations that can fail, and you want to accumulate context without deep nesting of try/catch blocks. Libraries like cats and failjure provide production-ready implementations.

Pattern 4: Using the slingshot Library

slingshot by Chouser is a powerful library that extends Clojure's throw/catch mechanism. It allows you to throw and catch not just exceptions, but arbitrary values—including keywords, maps, and custom predicates.

(require '[slingshot.core :refer [throw+ try+]])

(defn check-permission [user resource]
  (if (contains? (:permissions user) resource)
    true
    (throw+ {:type ::forbidden
             :user (:id user)
             :resource resource
             :msg "Access denied"})))

(defn access-resource [user resource]
  (try+
    (check-permission user resource)
    (str "Accessing " resource)
    (catch {:type ::forbidden} e
      (str "Denied: " (:msg e) " for user " (:user e)))
    (catch Exception e
      (str "Unexpected error: " (.getMessage e)))))

;; Usage
(access-resource {:id "alice" :permissions #{:read :write}} :read)
;; => "Accessing :read"

(access-resource {:id "alice" :permissions #{:read}} :write)
;; => "Denied: Access denied for user alice"

slingshot is particularly useful for domain-level errors that shouldn't be Java exceptions. You can throw and catch keywords, maps with types, or any Clojure value, making error handling more expressive and less reliant on exception class hierarchies.

Pattern 5: Error Handling in core.async

In asynchronous pipelines built with core.async, errors must be propagated through channels. A common pattern is to wrap results in a map that indicates success or failure, then use dedicated error channels.

(require '[clojure.core.async :as async :refer [go ! chan]])

(defn async-divide [n d result-ch]
  (go
    (try
      (let [result (/ n d)]
        (>! result-ch {:status :ok :value result}))
      (catch Exception e
        (>! result-ch {:status :error
                       :message (.getMessage e)
                       :exception e})))))

(defn consume-results [result-ch]
  (go
    (loop []
      (when-let [msg (

Separate Error Channels

For systems where errors need special handling, use a dedicated error channel:

(defn robust-worker [input-ch result-ch error-ch]
  (go
    (loop []
      (when-let [task (! result-ch {:task task :result result}))
          (catch Exception e
            (>! error-ch {:task task
                          :error (.getMessage e)
                          :timestamp (System/currentTimeMillis)})))
        (recur)))))

;; Error consumer with retry logic
(defn error-handler [error-ch retry-ch]
  (go
    (loop []
      (when-let [err (! retry-ch (:task err)))
        (recur)))))

Pattern 6: Validation with Spec and Error Accumulation

clojure.spec provides a powerful validation framework. Instead of throwing on the first error, you can accumulate all validation failures using explain-data and custom wrappers.

(require '[clojure.spec.alpha :as s])

(s/def ::age (s/and int? pos? (fn [n] (< n 150))))
(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def ::user (s/keys :req-un [::age ::email]))

(defn validate-user-spec [user]
  (let [result (s/explain-data ::user user)]
    (if result
      {:valid false
       :problems (mapv #(select-keys % [:path :pred :val :reason])
                       (::s/problems result))}
      {:valid true :user user})))

;; Usage
(validate-user-spec {:age 25 :email "alice@example.com"})
;; => {:valid true, :user {:age 25, :email "alice@example.com"}}

(validate-user-spec {:age "twenty-five" :email "invalid"})
;; => {:valid false,
;;     :problems [{:path [:age], :pred `int?, :val "twenty-five"}
;;                {:path [:email], :pred (fn [%] ...), :val "invalid"}]}

This pattern is ideal for form validation, API input checking, and any scenario where you want to report all errors to the user at once, rather than failing on the first invalid field.

Best Practices for Error Handling in Clojure

  • Use ex-info for domain errors — When throwing exceptions, always attach contextual data. A bare string message is rarely enough for debugging.
  • Distinguish expected failures from bugs — Use error-as-value patterns for expected failure modes (invalid input, not found) and exceptions for truly unexpected states (programming errors, invariant violations).
  • Don't catch Exception blindly — Catch specific exception types. A blanket (catch Exception e ...) can swallow critical errors like OutOfMemoryError or ThreadDeath.
  • Preserve the original exception — When wrapping errors, include the original exception as a cause or in the ex-data map. This preserves the stack trace for debugging.
  • Log at the boundary — Handle errors at the edges of your system (API handlers, queue consumers, CLI commands). Internal functions should propagate errors cleanly.
  • Use finally for resource cleanup — For file handles, database connections, or locks, always use finally or with-open to ensure cleanup regardless of errors.
  • Consider slingshot for complex error flows — When you need to catch based on error categories (not just exception types), slingshot's selector mechanism is cleaner than multiple catch blocks with instance? checks.
  • Test error paths explicitly — Write unit tests for error cases just as thoroughly as success cases. Use (is (thrown? ...)) or (is (thrown-with-msg? ...)) for exception-based code, and plain value assertions for error-as-value patterns.
  • Be mindful of async error propagation — In core.async go blocks, uncaught exceptions are silently swallowed. Always wrap go block bodies in try/catch and explicitly route errors to channels.
  • Document error types — In function docstrings, note what errors a function throws or returns. This is especially important for library code consumed by other developers.

Real-World Example: A Resilient HTTP Client Wrapper

Here's a practical example combining several patterns—structured exceptions, error-as-value, retry logic, and proper cleanup:

(require '[clj-http.client :as http])

(defn fetch-with-retry
  "Fetches a URL with exponential backoff retry.
   Returns {:status :ok :body ...} on success,
   or {:status :error :attempts ... :last-error ...} on failure."
  [url max-retries]
  (let [attempt (atom 0)]
    (loop []
      (try
        (let [response (http/get url {:socket-timeout 5000
                                      :conn-timeout 5000})
              status (:status response)]
          (if (= 200 status)
            {:status :ok :body (:body response)}
            (throw (ex-info "Non-200 response"
                            {:url url
                             :http-status status
                             :body (:body response)}))))
        (catch Exception e
          (let [current-attempt (swap! attempt inc)]
            (if (< current-attempt max-retries)
              (do
                (Thread/sleep (* 1000 current-attempt)) ;; exponential backoff
                (recur))
              {:status :error
               :url url
               :attempts current-attempt
               :max-retries max-retries
               :last-error (ex-message e)
               :error-data (when (instance? clojure.lang.ExceptionInfo e)
                             (ex-data e))})))))))

;; Usage with pattern matching on result
(defn display-result [result]
  (case (:status result)
    :ok    (println "Response:" (subs (:body result) 0 100) "...")
    :error (do
             (println "Failed after" (:attempts result) "attempts")
             (println "Last error:" (:last-error result))
             (when-let [data (:error-data result)]
               (println "HTTP Status:" (:http-status data))))))

;; Execute
(display-result (fetch-with-retry "https://httpbin.org/status/200" 3))
(display-result (fetch-with-retry "https://httpbin.org/status/500" 3))

Conclusion

Error handling in Clojure is a rich topic that spans from basic JVM interop to sophisticated functional patterns. The language's philosophy of data-first programming extends naturally to error representation—whether you're attaching maps to exceptions with ex-info, returning tagged values for expected failures, or composing error-aware pipelines with Either monads. The key insight is that errors are not just control-flow interruptions; they are data that flows through your system. By treating them as such—structuring them, propagating them explicitly, and handling them at the right boundaries—you build Clojure applications that are easier to debug, more resilient in production, and more pleasant to maintain. Choose the pattern that fits your context: ex-info for JVM-facing exceptions, error-as-value for pure functions, slingshot for domain-level error routing, core.async patterns for asynchronous code, and spec for input validation with error accumulation. Whichever you pick, the goal is the same: make errors visible, actionable, and recoverable.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles