Understand why immutable updates stay practical on the JVM, and when transients are the right optimization tool.
If every immutable update copied an entire collection, idiomatic Clojure would be too expensive to use seriously.
It is not.
The reason is structural sharing: when you create an updated collection, Clojure usually reuses the unchanged parts of the old collection instead of copying everything.
That is what makes immutable programming practical on the JVM rather than merely principled.
Suppose you update one nested value in an order:
1(def order
2 {:order/id 1001
3 :customer {:customer/id 77
4 :email "ada@example.com"}
5 :items [{:sku "A-1" :qty 2}
6 {:sku "B-2" :qty 1}]})
7
8(def updated-order
9 (assoc-in order [:items 1 :qty] 3))
You now have two valid values:
orderupdated-orderThe important implementation idea is that Clojure does not need to rebuild the entire world from scratch. It rebuilds only the parts along the changed path and reuses the rest.
The diagram below is conceptual rather than a literal object graph, but it shows the important idea: unchanged branches are shared between the old and new values.
graph TD
A["Original order"] --> B["Customer map (shared)"]
A --> C["Items vector v1"]
C --> D["Item 1 map (shared)"]
C --> E["Item 2 map, qty 1"]
F["Updated order"] --> B
F --> G["Items vector v2"]
G --> D
G --> H["Item 2 map, qty 3"]
What to notice:
That is why immutable updates are usually much cheaper than “copy every element.”
Structural sharing preserves the main practical performance benefit you need from immutable collections:
You still allocate some new structure, of course. Immutability is not free. But the cost is targeted, not naive.
For everyday application code, that trade-off is excellent because you get:
all at the same time.
This is the mental trap many Java engineers hit first.
They imagine:
That is usually the wrong model. Persistent collections keep the older version available while reusing unchanged structure.
This is one reason Clojure’s immutable collections behave more like purpose-built data structures than like defensive-copy patterns.
Structural sharing does not mean:
It means Clojure’s default collections are engineered so that immutable programming remains practical for real systems.
That is a much more defensible claim.
Sometimes you are building up a large collection in a tight local loop and profiling shows the persistent version is a hot spot.
That is where transients can help.
Transients give you a temporary, controlled optimization path for building a persistent result more efficiently, while preserving the same broad functional structure.
Example:
1(defn ids-over-limit [orders limit]
2 (persistent!
3 (reduce (fn [acc {:keys [total] :as order}]
4 (if (> total limit)
5 (conj! acc (:order/id order))
6 acc))
7 (transient [])
8 orders)))
The important idea is not “mutate everything now.” It is:
Transients are an optimization tool, not the default design model.
Do not start with transients because you are nervous about immutability.
Start with ordinary persistent code first.
Only consider transients when:
That is the same discipline good Java teams use with lower-level performance tuning: measure first, optimize second.
For early Clojure work, this is the right model to internalize:
Once you trust that, Clojure’s value-oriented style becomes much easier to adopt honestly.