Contents

Transducers and Reducers, Finally Explained (For People Like Me)

Let’s be completely honest with each other.

If you have spent any time around Clojure, functional programming, or modern Lisp communities over the last decade, you have almost certainly encountered people speaking about Transducers and Reducers in hushed, reverent tones—as if they were forbidden alien technology recovered from a crashed UFO in Roswell.

You open the documentation, and you are immediately greeted by sentences like:

“Transducers are composable algorithmic transformations decoupled from their input or output sources. A transducer is a function that accepts a reducing function and returns a new reducing function.”

Cool story. Very impressive.

Meanwhile, you’re sitting at your desk thinking: “Look, I just have ten million telemetry records in an array, and I want to filter the bad ones, parse the timestamps, and sum the durations without my laptop fan taking off like an F-16 fighter jet. Why does (map inc) suddenly take only one argument? Where did my collection go? What is a reducing function? And why on earth is it called a transducer instead of just a pipe?”

For years, tutorials on transducers have suffered from the classic “Monad Tutorial Curse”: they either drown you in abstract category theory or show you an example so trivial ((into [] (map inc) [1 2 3])) that you walk away thinking, “Wait, why didn’t I just use regular map?”

Today, we are throwing all of that academic pretension into the shredder.

We are going to break down Transducers and Reducers from first principles, explain the exact mechanical problems they solve inside your CPU and RAM, build them from scratch in five minutes, and show how you can use them in Coni (and Clojure) to write high-throughput, zero-waste, multi-core data pipelines.

Grab your beverage of choice. Let’s demystify this once and for all.


1. The Crime of Intermediate Collections

To understand why transducers exist, we first have to understand the crime we commit every single day with standard functional collections.

Suppose you write this perfectly innocent pipeline:

(->> (range 1000000)
     (map inc)
     (filter even?)
     (take 5))

It looks gorgeous. It reads like English. It is the pride of functional programming.

Now, let’s look at what your computer actually has to do to execute this if implemented naively (like in Python, JavaScript array methods, or eager collections):

Think about the sheer, unadulterated absurdity of this:

  1. range constructs a collection of 1,000,000 items.
  2. map allocates a brand new collection of 1,000,000 items and fills it with incremented numbers.
  3. filter allocates a third collection of 500,000 even numbers.
  4. take grabs the first five items and throws everything else into the garbage collector.

You allocated millions of heap objects, trashed your CPU L1/L2 cache, triggered garbage collection pauses, and burned memory bandwidth—all to look at five numbers.

“Wait, Don’t Lazy Sequences Fix This?”

Clojure hackers will immediately chime in: “Ah, but Clojure sequences are lazy! It only computes items on demand!”

Yes, laziness avoids computing all 1,000,000 items upfront. But laziness comes with its own heavy tax:

  • Every step in a lazy sequence creates a LazySeq wrapper object on the heap.
  • Clojure chunks lazy sequences in blocks of 32 items, meaning you’re still allocating chunk buffers and realization objects.
  • Realizing lazy sequences incurs pointer chasing, cache misses, and significant overhead in tight loops.

What if we didn’t want lazy wrappers? What if we didn’t want intermediate collections at all?

What if the numbers 0, 1, 2, 3, 4 simply flowed through inc → even? → take directly into the final vector in a single pass, without creating a single temporary collection anywhere in memory?


2. The Big Realization: Everything is Just reduce

Before we talk about transducers, we have to look at the humble reduce.

Most programmers learn reduce as a way to sum an array:

(reduce + 0 [1 2 3 4]) ;; => 10

Because of this, people mentally bucket reduce as an “aggregation” tool for math. This is a massive mistake.

In functional programming, reduce is the foundational primitive of computation. Almost any operation on a sequence can be expressed as a reduction.

What is the function passed to reduce? It’s called a reducing function (or rf):

;; The reducing function signature:
(rf accumulator input) -> new-accumulator

Look closely at these standard functions:

;; Math addition is a reducing function:
(+ 10 5)                       ;; => 15

;; Building a vector with conj is a reducing function:
(conj [1 2] 3)                 ;; => [1 2 3]

;; Building a map with assoc is a reducing function:
(assoc {:a 1} :b 2)            ;; => {:a 1 :b 2}

;; Pushing to a channel or socket is a reducing function:
(fn [ch item] (>! ch item) ch) ;; sends item, returns ch

If building a collection with conj is just a reducing function:

(reduce conj [] [1 2 3 4]) ;; => [1 2 3 4]

Then what if we want to map over a collection using reduce?

(defn map-reduce [f coll]
  (reduce (fn [acc x]
            (conj acc (f x)))
          []
          coll))

(map-reduce inc [1 2 3]) ;; => [2 3 4]

And what if we want to filter a collection using reduce?

(defn filter-reduce [pred coll]
  (reduce (fn [acc x]
            (if (pred x)
              (conj acc x)
              acc))
          []
          coll))

(filter-reduce even? [1 2 3 4]) ;; => [2 4]

Notice something profound happening here: In both map-reduce and filter-reduce, the logic of how to transform an item ((f x) or (if (pred x) ...)) is hardcoded to call conj and output to a vector [].

What if we decoupled the transformation from conj?

What if our transformation didn’t care whether the output was being conj-ed into a vector, added with +, written to an SQLite database, or streamed over a WebSocket?


3. Building a Transducer from Scratch

Let’s extract the transformation logic.

Look at our mapping reducing function again:

(fn [acc x]
  (conj acc (f x)))

Why did we hardcode conj? Let’s make conj a parameter called rf (the next reducing function):

(defn mapping [f]
  (fn [rf]
    (fn [acc x]
      (rf acc (f x)))))

Read those six lines very carefully. Stare at them until your third eye opens.

  1. mapping takes a transformation function f (like inc).
  2. It returns a function that takes a reducing function rf (like conj or +).
  3. It returns a new reducing function that takes (acc, x), applies (f x), and forwards the result to rf!

Let’s test it:

;; Create a mapper that increments
(def inc-transducer (mapping inc))

;; Wrap conj with it!
(def inc-and-conj (inc-transducer conj))

;; inc-and-conj is now a regular reducing function!
(inc-and-conj [] 10) ;; => [11]
(inc-and-conj [11] 20) ;; => [11 21]

;; We can feed it directly to reduce!
(reduce inc-and-conj [] [1 2 3]) ;; => [2 3 4]

Now let’s do the exact same thing for filter:

(defn filtering [pred]
  (fn [rf]
    (fn [acc x]
      (if (pred x)
        (rf acc x)
        acc))))

Let’s test filtering:

(def even-transducer (filtering even?))
(def filter-and-conj (even-transducer conj))

(filter-and-conj [] 1) ;; => [] (skipped!)
(filter-and-conj [] 2) ;; => [2]

(reduce filter-and-conj [] [1 2 3 4 5 6]) ;; => [2 4 6]

Congratulations. You have just invented Transducers.

A transducer is not a data structure. A transducer is not a sequence.

;; The transducer signature:
transducer: rf -> rf'

4. The Mind-Bending Magic of Composition (comp)

Now comes the part that makes everyone’s brain hurt the first time they see it.

In normal functional programming, function composition (comp f g) applies right-to-left: (f (g x)).

So when people see:

(def xform (comp (filter even?)
                 (map inc)))

They assume it increments first, then filters. It does the exact opposite! It filters even numbers FIRST, then increments them!

Why? Because of how wrappers nest! Think of Russian nesting dolls or an onion:

When you run (xform conj):

  1. (map inc) wraps conj. It says: “Whenever you give me an item, I will increment it, and then call conj.”
  2. (filter even?) wraps that entire package. It says: “Whenever you give me an item, if it’s even, I will pass it to the map-wrapper; if it’s odd, I will ignore it.”

When an item enters the pipeline, it hits the outermost wrapper first (filter), and only if it survives does it reach the inner wrapper (map), which finally calls the terminal accumulator (conj)!

Transducer pipelines execute left-to-right in the natural order you write them.

Zero intermediate collections. Zero wrapper objects. Just one pipeline of function calls per element.


5. The Full Transducer Protocol: 0, 1, and 2 Arities

In real production systems (like Clojure and Coni), a reducing step function needs to handle more than just receiving items. It needs a complete lifecycle:

  1. 0-arity (): Initialization / Identity. Returns the default accumulator if none was provided (e.g. (+) => 0, (*) => 1, (conj) => []).
  2. 1-arity (acc): Completion / Flush. Called once when the input stream is finished. This is crucial for stateful transducers like partition-all that need to flush trailing buffer items, or transducers that need to close file handles.
  3. 2-arity (acc, x): Step. The standard step function we already built.

Here is what a complete, production-grade transducer looks like in Coni:

(defn map [f]
  (fn [rf]
    (fn [& args]
      (case (count args)
        ;; 0-arity: Init
        0 (rf)
        ;; 1-arity: Completion
        1 (rf (first args))
        ;; 2-arity: Step
        2 (let [acc (first args)
                val (second args)]
            (rf acc (f val)))))))

Early Termination with reduced

What about operations like (take 5)? In a normal loop, you break. In a traditional reduce, you can’t break—it keeps running until the collection ends.

In Coni and Clojure, early termination is achieved with reduced:

(defn take [n]
  (fn [rf]
    (let [na (atom n)]
      (fn [& args]
        (case (count args)
          0 (rf)
          1 (rf (first args))
          2 (let [acc (first args)
                  val (second args)
                  remaining (swap! na dec)]
              (cond
                (pos? remaining)
                (rf acc val)

                (zero? remaining)
                ;; Yield this final value and wrap accumulator in reduced!
                (let [ret (rf acc val)]
                  (if (reduced? ret) ret (reduced ret)))

                :else
                (ensure-reduced acc))))))))

When a reducing function returns (reduced val), the reduce engine stops iterating immediately.

Even on an infinite stream, take halts instantly without hanging:

(into [] (take 5) (range))
;; => [0 1 2 3 4] (Instantaneous! Never tries to evaluate infinity)

6. The Four Transducer Verbs You Need in Daily Life

You don’t need to write custom transducers from scratch every day. Coni’s standard library provides transducers out of the box for almost every core sequence function:

map, filter, remove, take, drop, take-while, drop-while, take-nth, keep, keep-indexed, map-indexed, distinct, dedupe, cat, mapcat, partition-all, partition-by, interpose, and random-sample.

Whenever you call them without a collection argument, they return a transducer!

;; With collection: regular sequence
(map inc [1 2 3]) ;; => '(2 3 4)

;; Without collection: returns a TRANSDUCER!
(map inc)         ;; => #<Function transducer>

Here are the four execution verbs to run them:

1. into (Produce a new collection)

Use when you want the transformed result gathered into a vector, set, map, or list:

;; Collect into a vector:
(into [] (comp (filter odd?) (map (fn [x] (* x 10)))) [1 2 3 4 5 6])
;; => [10 30 50]

;; Collect into a set (automatically deduplicates!):
(into #{} (map (fn [x] (mod x 3))) [1 2 3 4 5 6 7 8 9])
;; => #{0 1 2}

;; Transform key-value entries into a map:
(into {} (map (fn [[k v]] [k (inc v)])) {:a 1 :b 2})
;; => {:a 2 :b 3}

2. transduce (Eager reduction into a single value)

Use when you want to compute a summary (sum, average, count, hash) without any intermediate collection:

;; 4-arity: (transduce xform f init coll)
(transduce (filter even?) + 0 [1 2 3 4 5 6])
;; => 12

;; 3-arity: (transduce xform f coll) where (f) supplies initial 0-arity value
(transduce (filter even?) + [1 2 3 4 5 6])
;; => 12

3. sequence (Lazy evaluation on demand)

Use when the input data is huge and you want to pull items lazily on demand:

(def seq-view (sequence (comp (filter even?) (map inc)) [1 2 3 4 5]))
;; Evaluates lazily as consumed

4. eduction (A reusable recipe view)

An eduction bundles one or more transducers with a collection. It does not calculate anything upfront. Every time you reduce it, the transduction pipeline runs cleanly:

(def my-recipe (eduction (filter odd?) (map (fn [x] (* x 10))) [1 2 3 4 5]))

(reduce + 0 my-recipe) ;; => 90
(into [] my-recipe)    ;; => [10 30 50]

7. Part Two: Reducers (clojure.core.reducers)

Now that you understand transducers, we can tackle the second topic that confuses everyone: Reducers.

People often ask: “Wait, if transducers already do zero-allocation, composable transformations, why do Reducers exist?”

Here is the difference in one sentence:

  • Transducers define WHAT transformations happen to data (the algorithmic recipe).
  • Reducers define HOW data is consumed (specifically: parallel multi-core divide-and-conquer).

The Problem with Sequential Reduce

Normal reduce is single-threaded. It processes element 0, then element 1, then element 2, up to element N.

If you have a collection of 50,000,000 integers and a 16-core Apple Silicon M3 Max, sequential reduce runs on exactly one core while the other 15 cores sit there idle, doing nothing.

Can we parallelize reduce?

Only if the operation is associative!

Addition is associative: (a + b) + (c + d) = ((a + b) + c) + d.

Because grouping doesn’t matter, we can split a huge vector in half, calculate the sum of the left half on CPU Core 1, calculate the sum of the right half on CPU Core 2, and add the two totals together!

                    [1, 2, 3, 4, 5, 6, 7, 8]
                            /        \
                    [1, 2, 3, 4]    [5, 6, 7, 8]
                       /    \          /    \
                    [1, 2]  [3, 4]  [5, 6]  [7, 8]
                      |       |       |       |
                     (3)     (7)     (11)    (15)
                       \     /         \     /
                         (10)            (26)
                           \              /
                                 (36)

This is called Fork-Join Parallel Reduction.

Coni’s Go-Powered r/fold

In Coni’s new reducers library (libs/reducers/src/reducers.coni), r/fold is implemented using native Go goroutines (spawn) and channels (chan):

(require "libs/reducers/src/reducers.coni" :as r)

(defn fold
  "Parallel divide-and-conquer reduction backed by Coni goroutines and channels.
   Arities:
     (fold reducef coll)
     (fold combinef reducef coll)
     (fold n combinef reducef coll)"
  [& args]
  ...
  (let [sz (count coll-vec)]
    (if (<= sz n)
      ;; Leaf node: chunk is small enough, reduce sequentially
      (root-reduce reducef (combinef) coll-vec)
      ;; Tree node: split in halves and fork in parallel goroutines!
      (let [half (int (/ sz 2))
            left (subvec coll-vec 0 half)
            right (subvec coll-vec half sz)
            ch (chan 1)]
        (spawn (fn []
                 (>! ch (fold n combinef reducef right))))
        (let [res-left (fold n combinef reducef left)
              res-right (<! ch)]
          (combinef res-left res-right))))))

Look at how elegant that is:

  1. If the chunk size is smaller than n (default: 512 items), it evaluates sequentially using reduce.
  2. If the chunk size is larger than n, it splits into left and right.
  3. It spawns a background OS-thread goroutine (spawn) to fold the right half, while the current thread folds the left half.
  4. It receives the right half’s result from channel (<! ch) and merges them using combinef.

Let’s test parallel reduction:

(def big-data (vec (range 1000000)))

;; Sum 1,000,000 numbers across all CPU cores in parallel!
(r/fold + big-data)
;; => 499999500000

Reducer Combinators

The reducers library also provides combinators (r/map, r/filter, r/take, r/cat):

(require "libs/reducers/src/reducers.coni" :as r)

;; Build a reducible pipeline (computes NOTHING upfront)
(def pipeline (r/take 5 (r/filter even? (r/map inc [1 2 3 4 5 6 7 8 9 10]))))

;; Execute sequential reduction
(r/reduce + 0 pipeline)
;; => 20

8. Summary: The Mental Cheat Sheet

Here is the quick mental model to keep in your pocket:

Concept What it is When to use it
Standard Sequences (map, filter) Lazy or eager functions coupled to collections. Quick scripts, small lists (< 1,000 items), simple REPL exploration.
Transducers ((map f), (filter p), into, transduce) Pure, composable transformation recipes decoupled from input/output. Zero intermediate collection allocations. High-throughput data transformation, ETL pipelines, streaming events, web sockets, channels.
Reducers (r/reducer, r/fold) Execution engines that control how reductions occur. Huge in-memory datasets (> 100,000 items) that can be split and processed in parallel across all CPU cores.

Final Thoughts

Transducers and reducers aren’t academic theory. They are practical engineering solutions to real hardware constraints: memory bandwidth, heap allocation pressure, and multi-core CPU utilization.

Once you realize that a transducer is just a function that wraps a step function, the mystery evaporates. You stop thinking about containers and start thinking about the flow of data.

Both transducers and reducers are now live in Coni. Take them for a spin, benchmark your pipelines, and write some clean, blazing-fast functional code!