Debugging the Clojure case statement
The Production Bug That Taught Us Why Clojure's Case Statements Don't Work Like You Think
The Production Nightmare
It was 9 AM on a Wednesday when I received a Slack message notifying me that Swym’s attribution data was not flowing in. I was asked if this was related to my change, as I had recently deployed to production and the data had not been flowing in since then.
After skimming through the code, I didn't find any issues that might have caused this problem. However, since my code changes were the only changes related to the attribution logic and were deployed to production on the given day, it made sense to revert the code. That did fix our production environment, but we were still clueless on the actual root cause.
After a few hours of debugging, I tracked the issue to a failing Clojure case statement. It was something like this.
;; Simplified for demonstration purpose
(defn handle-attribution-type [attribution-type]
(let [UTM :utm
WEBHOOK :webhook
WIDGET :widget]
(case attribution-type
UTM
;; do something here
(println "UTM")
WEBHOOK
;; do something here
(println "WEBHOOK")
WIDGET
;; do something here
(println "WIDGET")
;; default case
(println "Invalid attribution type" attribution-type))))
No matter what value was present in attribution-type, only the default expression seemed to get fired, which didn't make any sense.
(handle-attribution-type :utm) ;; Prints "Invalid attribution type"
(handle-attribution-type :webhook) ;; Prints "Invalid attribution type"
(handle-attribution-type :widget) ;; Prints "Invalid attribution type"
(handle-attribution-type :invalid) ;; Prints "Invalid attribution type"
Given this confusion, I had to summon help from our very own Clojure wizard at Swym, Mr. Anuj Seth. Anuj ran a few tests on the code and got back to me with the Clojure documentation. As it's mentioned here
;; Other example on
;; "The test-constants are not evaluated. They must be compile-time
;; literals, and need not be quoted."
(def one 1)
;;=> #'user/one
(case 1
one :one
:not-one)
;; => :not-one
"The test-constants are not evaluated. They must be compile-time literals, and need not be quoted."
This means that Clojure requires the match case to be literal values (strings, keywords, numbers, etc.). Only then will the case condition match. Let's test this with an example. You can fire up repl and execute this code:
(def ONE 1)
(def TWO 2)
(def THREE 3)
(defn number-type [n]
(case n
ONE "one"
TWO "two"
THREE "three"
"unknown"))
(number-type 1) ;; => "unknown"
We can see that even though we have passed 1 as a parameter to number-type, we still get the result “unknown”. However, if we change the match condition to literal values, we get the correct result:
(defn number-type [n]
(case n
1 "one"
2 "two"
3 "three"
"unknown"))
(number-type 1) ;; => "one"
Going through the docs, this seems to be a design choice in Clojure to increase speed and efficiency.
This design serves several purposes:
Performance: Case statements are compiled to efficient jump tables, not evaluated at runtime. The case statement performs a constant-time dispatch when using the case keyword.
Predictability: The behavior is deterministic and doesn’t depend on runtime variable values
Compile-time optimization: The compiler can optimize the dispatch logic
The Bytecode Investigation
So now that we knew we had to use only literal values, we went ahead and fixed the code. I was, however, still curious to know what test values would match the conditions if we used the variables themselves.
So, I compiled the Clojure code and disassembled it into bytecode so it can be analyzed.
;; src/demo/core.clj
(def ONE 1)
(def TWO 2)
(def THREE 3)
(defn number-type [n]
(case n
ONE "one"
TWO "two"
THREE "three"
"unknown"))
lein uberjar
javap -v target/classes/demo/core\$number_type.class
Skimming through the code, I came across these lines, which execute the case match condition.
59: aload_1 // Load first param ( n )
60: getstatic #30 // Load second param (ONE)
63: invokestatic #25 // Call equiv(n, ONE)
66: ifeq 89 // If not equal, goto default
69: ldc #32 // Load string "one"
71: goto 91 // Return "one"
What we are interested in is getstatic #30. This is the value of the match condition. It is fetched from the JVM constant pool with reference id #30. Let's see what is at the location.
The bytecode below shows that a string is fetched from #62 and converted to a Clojure symbol object and then stored at #30.
13: ldc #62 // Load string
15: invokestatic #58 // Method clojure/lang/Symbol.intern:(Ljava/lang/String;Ljava/lang/String;)Lclojure/lang/Symbol;
18: checkcast #60 // class clojure/lang/AFn
21: putstatic #30 // Field const__1:Lclojure/lang/AFn;
At #62, we see that it loads #61 as a string object. #61 stores the value “ONE” as a UTF-8 type.
#61 = Utf8 ONE
#62 = String #61 // ONE
What this meant was that the code should match the variable name “ONE” as a symbol. That is:
(def ONE 1)
(def TWO 2)
(def THREE 3)
(defn number-type [n]
(case n
ONE "one"
TWO "two"
THREE "three"
"unknown"))
(number-type 'ONE) ;; => "one"
(number-type 'TWO) ;; => "two"
(number-type 'THREE) ;; => "three"
Bazinga! So Clojure compares the test case value with the actual variable name as a symbol type.
Alternatives to the case statement
If we want to match our test value against constant bindings, a suitable alternative is the condp statement
(def ONE 1)
(def TWO 2)
(def THREE 3)
(defn number-type-condp [n]
(condp = n
ONE "one"
TWO "two"
THREE "three"
"unknown"))
(number-type-condp 'ONE) ;; => "unknown"
(number-type-condp 1) ;; => "one"
The performance of this method might not be as efficient as the case statement, but it will let you use bindings at runtime to match conditions.
Lessons Learned
This debugging session taught us several valuable lessons:
Always read the documentation carefully — especially for language constructs that seem simple
Test with literal values first — when debugging complex expressions, simplify to basics
Understand the compilation model — knowing how your code gets compiled can reveal hidden issues
Don’t assume familiar syntax works the same way — case statements in different languages have different semantics
Write thorough test cases to ensure any code changes made do not break existing functionality.
Conclusion
The case statement’s requirement for literal values is a feature, not a bug, but it’s easy to miss if you’re not familiar with the language’s semantics.
The next time you encounter a mysterious bug in Clojure, remember: sometimes the issue isn’t in your logic, but in your understanding of how the language evaluates expressions. And when in doubt, check the documentation — it might just save you hours of debugging.
Lovely Lezwon. Lessons for a lifetime, I guess. Thanx for sharing!