~ / track D / clojure in practice

Processing pipelines: threading, for, transducers

Intermediatepractice

Real programs spend most of their time turning one shape of data into another: log lines into a dashboard, rows into a report, events into a running total. Clojure gives you three idiomatic ways to express that transformation — -‌>‌> threading, sequence comprehensions, and transducers — and each fits a different situation. The choice is fundamentally imperative vs declarative: describe the transformation as data flow, not as mutable steps. Knowing which form to reach for is the practical skill the standard library hides behind its eighty-something seq functions.

The default: thread with -‌>‌>

The -‌>‌> macro reads top-to-bottom, each step takes the previous step's output as its last argument, and the whole pipeline is a single expression. For most data work this is the clearest possible form:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

You can read it as a recipe: take this collection, keep the hits, pull out the latency, sum them up. No intermediate names, no loops, no off-by-one.

The cost: each stage walks the collection and allocates an intermediate seq. For five elements that's free; for fifty million it isn't.

Comprehensions when the shape is "for each X, for each Y"

When the transformation is most naturally read as nested iteration with a filter, f‌o‌r makes the structure explicit:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

A two-binding f‌o‌r with :when is often clearer than mapcat + f‌i‌l‌t‌e‌r, and :let lets you compute a value once and use it in both the predicate and the body. Use it when the loop structure is the explanation.

Transducers when the source is huge or unknown

A transducer is a transformation factored out from the collection it runs on. The same (comp (filter …) (map …) (partition-by …)) value can run once over a vector, again over a channel, and once more over a lazy seq, and no intermediate seqs are ever materialised. This is the right tool when the pipeline is hot, the data is large, or the source isn't a collection at all.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Notice the pipeline (hit-latency) was built without mentioning the data. That same value works with i‌n‌t‌o, sequence, transduce, and core.async channels — the transducer is the what, the call site supplies the how.

The threading version and the transducer version compute the same answer; the transducer version walks the input exactly once and allocates nothing in between.

Choosing the form

A practical rule of thumb:

SituationReach for
Small-to-medium collection, one-shot transformation-‌>‌>
The shape is "for every X, for every Y, when Z"f‌o‌r
Hot path, large data, or the source is a channel/streamtransducer
You need the transformation as a value to reuse across sourcestransducer

Don't reach for transducers prematurely. The threading form is easier to read and almost always fast enough; the transducer pays off when you can point at a benchmark or a memory profile.

A worked example: tally hits per route

A small log analysis pipeline. Pull HTTP-style entries, keep the successful ones, group by route, count each group, and return the top-3:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

The same pipeline as a transducer, demonstrating the factoring:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Identical answer; the second version never allocates an intermediate seq of paths. Whether that matters depends on the input — the rule of thumb says "benchmark first."

In a Clojure service

Batch import endpoints are a natural fit for a -‌>‌> enrichment pipeline: each order in the request body passes through lookup, validation, and totalling as a single expression in the functional core. The handler stays thin — parse the body, call the pipeline, persist the enriched batch — while the pipeline itself is pure and testable without HTTP. When profiling shows the batch is large enough to matter, the same steps factor into a transducer without rewriting the logic.

(defn enrich-batch [db orders]                   ;; sketch
  (->> orders
       (map #(assoc % :customer (lookup-customer db (:customer-id %))))
       (map #(assoc % :fx-rate (lookup-fx db (:currency %))))
       (map compute-total)
       (filter valid-order?)))
 
(defn import-orders-handler [{:keys [db]} {:keys [body]}]
  {:status 200 :body (enrich-batch db (:orders body))})

Check yourself

? quiz

When is a transducer the right tool over a plain `->>` pipeline?

Exercise

The imperative version below totals spend per customer, then keeps only customers over 100. Rewrite pipeline->map as a single -‌>‌> pipeline that returns the same map — no a‌t‌o‌m, no d‌o‌s‌e‌q. Run until the assert passes.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
Reference solution
(defn pipeline->map [events]
  (->> events
       (group-by :customer)
       (map (fn [[c es]] [c (reduce + (map :amount es))]))
       (filter (fn [[_ total]] (> total 100)))
       (into {})))
 status: new