Reading — step 1 of 7
Learn
Clojure's persistent data structures are fast — but for hot paths, transients and reducers can give significant speedups. "Joy of Clojure" covers these as the performance toolbox.
Transients — local mutability
A transient is a temporarily-mutable version of a Clojure persistent collection. You build it imperatively, then convert back to persistent:
(defn build-vec [n]
(loop [i 0
v (transient [])]
(if (< i n)
(recur (inc i) (conj! v i))
(persistent! v))))
(build-vec 5) ;; [0 1 2 3 4]
(transient coll)— convert persistent to transientconj!,assoc!,disj!,dissoc!,pop!— mutating operations(persistent! tcoll)— convert back; you can't use transient after this
Rules:
- Transients are LOCAL — don't share between threads
- Don't read intermediate values — only the final
persistent!result is correct - Only the bang-suffixed variants of operations are valid
Performance: 2-3x faster for building large collections. Standard library uses transients internally for into, mapv, etc.
When to reach for transients
- Building a large vector / map / set in a loop
- Standard library's
intois already transient-optimized - For one-off persistent operations, the difference is negligible
;; Standard library uses transients:
(into {} (map (fn [n] [n (* n n)]) (range 100)))
Reducers — parallel reductions
The clojure.core.reducers namespace provides r/map, r/filter, r/fold that don't allocate intermediate lists:
(require '[clojure.core.reducers :as r])
(reduce + 0 (r/map #(* % 2) (range 10000)))
;; Same result as without r/, but no intermediate seq
r/fold further enables parallel reduction:
(r/fold + (vec (range 100000)))
;; Splits work across cores via fork/join
fold only works on foldable collections (vectors and some others). Lists don't support efficient splitting.
Transducers — composable transformations
(def xform (comp (map inc) (filter even?) (take 5)))
(into [] xform (range 100))
;; [2 4 6 8 10]
A transducer is a stateless function transformation independent of the input source. Same xform works on:
- Eager:
into,transduce - Lazy:
sequence - Channels:
chanwith xform argument
Transducers don't allocate intermediate seqs. Pure performance win for long pipelines.
Primitive math
;; Boxed (slow):
(defn sum [n]
(loop [i 0 acc 0]
(if (< i n) (recur (inc i) (+ acc i)) acc)))
;; Primitive (fast):
(defn sum [^long n]
(loop [i (long 0) acc (long 0)]
(if (< i n) (recur (inc i) (+ acc i)) acc)))
Clojure boxes longs by default. Type hints (^long) and (long ...) casts force primitive arithmetic. Crucial for numeric-heavy hot paths.
There's a 4-arity arithmetic limit — adding more than 4 longs at once boxes:
(+ a b c d) ;; primitive (4 args)
(+ a b c d e) ;; boxed (5+ args)
Profiling
- Criterium — micro-benchmarking library; handles JVM warmup
- VisualVM, YourKit — JVM profilers
(time ...)— built-in timing macro for ad-hoc checks
(time (reduce + 0 (range 1e6)))
;; "Elapsed time: 12.3 msecs"
When to optimize
Most Clojure code doesn't need any of this. Reach for it when:
- Profiling identifies a real hot path
- Numeric / collection-building work dominates
- You're writing a library (10x speedups matter to all consumers)
For application code, idiomatic Clojure is fast enough. Fancy tricks usually obscure intent.
Common mistakes
- Sharing transients across threads — they're not thread-safe. Local mutation only.
- Using
assocafterassoc!— once you've gone transient, use bang variants throughout. r/foldon a list — only works on foldable collections (vectors). Convert first.- Unboxed math without hints — Clojure boxes by default. Add ^long, ^double in numeric hot paths.
- Profiling without JIT warmup — use Criterium for accurate measurements; raw
timeis biased by warmup.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…