Clojure Transducers: One Pipeline, Many Contexts
Transducers detach a transformation from its source and sink. Define one pipeline with comp, then run it through into, sequence, and transduce.
10 May 2026, 01:59 UTC

You have a filtering-and-mapping pipeline that works over a vector. Now the same logic needs to run over a lazy sequence, and again inside a reduce that builds a summary. Copying the ->> thread into three places is the obvious move, and it is the one that quietly drifts: a step gets added to one copy and not the others.
Transducers are Clojure's answer to that specific problem. A transducer is a transformation with the source and the destination removed. Define it once, then hand it to into, sequence, transduce, or a core.async channel, and the same steps run in each context.
A transducer is a reducing function that takes a reducing function
A reducing function has the shape reduce expects: it takes an accumulator and an element and returns the next accumulator, as in (fn [acc x] ...). That is the only contract a transducer cares about.
Call map, filter, take, or dedupe with one argument instead of two and you get a transducer: a function that accepts a reducing function and returns a new one. Because the transducer never sees the collection, it cannot care whether the elements come from a vector, a lazy seq, or a channel.
Composition uses comp, with one wrinkle worth internalizing: transducer composition reads in pipeline order. In (comp (map inc) (filter even?)), map sees each element first, then filter. That matches the order you would write in a ->> thread, not the order ordinary function composition would apply.
A worked example: one transducer, three contexts
Define the pipeline once, in a REPL or in a namespace:
(def xf
(comp (map inc)
(filter even?)
(take 3)))
Nothing has been computed yet. xf is a function waiting for a reducing function. Now apply it in three different ways:
(into [] xf (range 10))
;; => [2 4 6]
(sequence xf (range 10))
;; => (2 4 6)
(transduce xf + 0 (range 10))
;; => 12
Those values follow from the code: inc turns 0–9 into 1–10, the even ones are 2, 4, 6, 8, 10, and take 3 keeps the first three. Run them in your own REPL to confirm rather than taking the comments on faith.
The important detail is that take is stateful, and reusing xf three times is still safe. The state lives in the reducing function the transducer builds when it is applied, not in the transducer value itself. Each call to into, sequence, or transduce constructs a fresh one.
Two related tools are worth knowing. eduction wraps a collection and a transducer into a reducible view that applies the steps each time it is reduced, without building an intermediate collection. And core.async channels accept a transducer as their second argument, so the same xf can filter a stream — but that requires the core.async library, which is not part of Clojure core.
Extract a transducer, or keep the thread macro?
Transducers are not a replacement for ->>. They are a tool for a narrower situation.
| Situation | Reasonable default |
|---|---|
| One transformation, one call site | Inline ->> thread |
| Same pipeline in several call sites or across a library boundary | Named transducer |
| Consuming without materializing an intermediate collection | transduce, into, or eduction |
| A reusable lazy view over a source | sequence or eduction |
The extraction test is reuse, not performance. If the pipeline exists in one place and reads clearly as a thread, leaving it alone is the better call.
Where transducers cost you
- Debugging is less transparent. With
->>you can drop aprnbetween steps. Inside a transducer you can still use(map (fn [x] (prn x) (f x))), but stack traces route through anonymous reducing functions and are harder to read. - Errors surface late. A malformed transducer pipeline is just a composed function; arity or logic mistakes often appear when it is applied, not when it is defined.
- No promised speedup. The engineering rationale is reuse and skipping intermediate collections. Whether a given pipeline is faster depends on the data and the sink, and should be measured rather than assumed.
- Laziness has edges.
sequenceis lazy and chunked, so it may realize more of the source than the number of elements it emits.intowith a transducer is eager.
What to do next
Pick one pipeline that already appears in two or more places. Extract it to a named def, replace one call site at a time, and add a test that runs it through transduce with a trivial reducing function such as conj or + and asserts the result. That test exercises the transformation without depending on any particular collection type, which is the whole point.
To check the API surface in your own environment, evaluate (doc transduce), (doc into), (doc sequence), and (doc eduction) in a REPL. These examples assume Clojure 1.10 or later; transducers have been in core since 1.7. Channel transducers are a core.async feature and require that dependency.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.