Improve migrated Clojure performance in the right order: measure first, remove reflection, choose data structures deliberately, control sequence allocation, and reserve low-level tactics for proven hot paths.
Optimizing Clojure code should be boring and evidence-driven. Java engineers often reach first for types, loops, and mutable structures because those tools are familiar. In Clojure, start by making the code correct and measurable, then optimize only the parts profiling identifies.
The wrong performance process is “rewrite idiomatic Clojure until it looks like Java.” The right process is “measure, locate the cost, apply the smallest optimization that preserves clarity.”
| Step | What to do |
|---|---|
| Preserve behavior | Add fixture tests so optimized code cannot silently change results. |
| Measure | Identify whether the cost is CPU, allocation, I/O, contention, startup, or conversion. |
| Remove reflection | Enable reflection warnings and type-hint Java interop boundaries. |
| Choose better data structures | Use vectors, maps, sets, primitive arrays, or transients only where they fit the access pattern. |
| Reduce intermediate allocation | Prefer reduce, transduce, or a composed reducing function when pipelines are hot. |
| Specialize hot paths | Use primitive hints, unchecked math, or mutable internals only after measurement justifies them. |
This order keeps performance work reviewable. Most migrated code should not start at the bottom of the table.
Reflection appears when Clojure cannot determine a Java method target at compile time. It is common near migrated Java APIs.
1(set! *warn-on-reflection* true)
2
3(defn customer-prefix [^String customer-id]
4 (.substring customer-id 0 3))
The type hint tells the compiler that customer-id is a String, so the Java method call can be emitted directly. Type hints are especially useful at interop boundaries, not as decoration on every local value.
| Reflection warning area | Better response |
|---|---|
| Java method call | Add a type hint at the boundary. |
| Java constructor or static method | Make the target class explicit. |
| Numeric loop | Profile first, then add primitive hints if needed. |
| Dynamic value from a map | Validate or convert at the boundary before hot code. |
Avoid type-hint clutter. If a hint does not remove a warning or help a proven hot path, it probably does not belong.
Java engineers are used to choosing between ArrayList, HashMap, HashSet, and arrays. Clojure has the same need for deliberate choice.
| Need | Start with |
|---|---|
| Indexed access | Vector |
| Membership checks | Set |
| Lookup by key | Map |
| Sequential stack-like processing | List or sequence, if that shape is natural |
| Tight numeric loop | Primitive array or Java interop after profiling |
| Temporary bulk construction | Transient collection, then persistent! |
Do not choose a list because it “looks Lisp-like.” In practical Clojure, vectors and maps are often the default data structures for application data.
An idiomatic pipeline is a good starting point.
1(defn taxable-total [orders]
2 (->> orders
3 (filter :order/taxable?)
4 (map :order/amount)
5 (reduce + 0M)))
If profiling shows this path is hot, a transducer can express the same transformation without producing intermediate lazy sequence steps.
1(defn taxable-total [orders]
2 (transduce (comp (filter :order/taxable?)
3 (map :order/amount))
4 +
5 0M
6 orders))
Use this when measurement shows allocation or sequence overhead matters. Do not replace every readable pipeline with a transducer just because transducers sound advanced.
Persistent maps are usually fine. When profiling shows that a tight loop builds a large map, transients can reduce allocation while keeping mutation local.
1(defn index-by-id [orders]
2 (persistent!
3 (reduce (fn [idx order]
4 (assoc! idx (:order/id order) order))
5 (transient {})
6 orders)))
The mutation is contained inside the function. Callers still receive an immutable map.
| Transient rule | Reason |
|---|---|
| Use only inside one function | Prevent hidden mutable state from leaking. |
Convert back with persistent! |
Preserve the normal immutable contract. |
| Keep the code simple | Transients are not worth making ordinary code hard to review. |
| Require profiling evidence | Use them for hot builders, not casual style. |
Clojure can use primitive arrays, type hints, mutable locals through loops, and Java interop for hot paths. These are useful tools, but they should be deliberate exceptions.
| Low-level tactic | Use when |
|---|---|
| Primitive arrays | Numeric or binary data dominates runtime and collection abstraction is too costly. |
loop / recur |
A tight iterative process is clearer or faster than higher-order functions. |
| Type hints | Reflection warnings or primitive math costs are measured. |
| Java interop | A Java library or API already solves the hot path well. |
| Unchecked math | Overflow behavior is acceptable and measured numeric overhead matters. |
When optimization changes the shape of the code, add comments that explain the measured reason. Future maintainers need to know it is intentional.