Using Clojure Transients for Fast Bulk Construction of Large Vectors
When building millions‑element vectors in Clojure, repeated immutable updates can kill performance. Transients give a mutable view that lets you bulk‑construct efficiently. This blog explains how, with a concrete 10 million‑int example, and outlines the trade‑offs you must consider.
29 Jun 2026, 21:39 UTC

Problem: Building Huge Collections in Clojure
When you need to create a vector with millions of elements, the naïve approach of repeatedly calling conj on an immutable vector looks simple, but it triggers a new copy of the underlying structure for every insertion. In practice this leads to high memory churn, long GC pauses, and slow build times. The question is: how do we construct such a large collection efficiently without sacrificing Clojure’s persistent guarantees?
Thesis: Transients Turn Immutable Vectors into Mutable Builders
Clojure’s transient API gives you a mutable “view” of a persistent collection. While the collection is in transient form, updates are performed in place with conj!, assoc!, etc., achieving amortized O(1) time per operation. Once all elements are added, you call persistent! to freeze the structure back into an immutable vector that can be safely shared.
How to Use Transients in Practice
1. Create a transient from an empty vector
;; Run in a REPL or a single-threaded function
(defn build-large-vector [n]
(let [tmp (transient [])]
...))
2. Bulk‑add elements with conj!
(doseq [i (range n)]
(conj! tmp i))
Because conj! mutates tmp in place, each iteration touches only the necessary nodes of the vector’s internal array‑map structure.
3. Convert back to a persistent vector
(persistent! tmp))
The returned value is an immutable vector that can be used like any other Clojure collection.
Concrete Example: 10 Million Integers
(defn build-10m []
(let [n 10000000
tmp (transient [])]
(doseq [i (range n)]
(conj! tmp i))
(persistent! tmp)))
To compare performance, wrap each version in a time form:
(time (build-10m)) ; transient version
(time (let [v []]
(doseq [i (range 10000000)]
(conj v i)))) ; pure persistent version
Typical results on a modern JVM show the transient build finishing in a fraction of the time of the persistent build. Memory usage peaks lower for the transient approach because the vector is not repeatedly copied.
Trade‑Offs and Limitations
- Not thread‑safe: Transients must be used in a single thread or under explicit synchronization. Sharing a transient across threads can corrupt data.
- Conversion cost: Calling
persistent!inside a loop defeats the purpose; it should only be called once after all mutations. - Limited API: Only a handful of functions (
transient,persistent!,assoc!,conj!,dissoc!,pop!) are available. Complex transformations may require multiple passes. - Debugging difficulty: Since transients are mutable, accidental sharing can lead to subtle bugs that are hard to trace.
Practical Checklist Before Using Transients
- Ensure the operation is truly bulk‑construction and not a series of isolated updates.
- Run the code in a single thread or guard the transient with a lock.
- Profile with
timeandclojure.tools.traceto confirm the performance gain. - Measure heap usage (e.g.,
jcmd VM.native_memoryor a profiler) to verify lower peak memory. - After conversion, treat the result as immutable; do not attempt to mutate it again.
Actionable Takeaway
If you need to build a large vector, map, or set from scratch, start by wrapping the empty collection in transient, perform all mutations with the exclamation‑suffixed functions, then call persistent! once. This simple pattern can shave seconds off build time and reduce GC pressure, especially on JVMs with limited heap. Just remember: keep the transient local, avoid sharing it across threads, and convert it only after the bulk work is finished.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.