Clojure for Beginners: Clojure `for`, `doseq` & List Comprehensions — Generate and Iterate with Clarity
Video: Clojure `for`, `doseq` & List Comprehensions — Generate and Iterate with Clarity | Episode 19 by CelesteAI
Clojure `for`, `doseq` & List Comprehensions — Generate and Iterate with Clarity
Imagine you're writing the seating chart for a wedding. Eight tables, ten guests per table, and you need every (table, seat) pair as a list. In Java you'd write a nested for loop and append to an ArrayList. In Python you'd reach for a list comprehension. In Clojure, you reach for for — and you get the entire seating chart back as one expression.
That's the whole pitch for for: describe the shape of the data you want, and Clojure generates it. No manual indexing, no accumulator, no "don't forget to clear the list before re-running." for is Clojure's list comprehension — give it one or more bindings and it hands back a lazy sequence of results. Add :when to filter, :let to name intermediate values, and you have a compact language for "generate me everything shaped like X." And when you don't want a list back — when you want side effects, like printing or writing to a file — doseq is the same shape, but eager and silent.
This episode walks through both, and the small handful of decisions you'll make every time you use them.
Why list comprehensions exist at all
Before for, you'd build a sequence by hand: map over an input, then filter the result, then maybe map again. Three function calls, three intermediate sequences, and you've lost the thing you were trying to express — "the list of pairs where x is odd and x*y > 100."
for compresses that pipeline. (for [x xs y ys :when (cond x y)] (f x y)) reads as one statement: for every x in xs and every y in ys, when the condition is true, give me f(x, y). The pipeline is implicit. The reader sees the shape of the answer instead of the choreography of the iteration.
The other thing for does that's easy to miss: it returns a lazy sequence. The body doesn't run until something pulls values out (a println, a take, an into). For a 10,000-row Cartesian product where you only need the first 50, for generates exactly 50 — not all 10,000.
The mental model: rightmost binding varies fastest
Multiple bindings nest, and the rule is the same as Cartesian-product index order: the rightmost binding varies fastest.
(for [x [:a :b :c] y [1 2]] [x y])
;; => ([:a 1] [:a 2] [:b 1] [:b 2] [:c 1] [:c 2])
x stays on :a while y cycles through 1, 2; then x moves to :b and y resets. If you're used to nested for loops, this is the inner loop — and x is the outer loop. Once you have this picture, the chess-board example below is obvious.
Code: from squares to Pythagorean triples
;; Episode 19: for, doseq, and List Comprehensions
;; Clojure for Beginners in Neovim
(println "== for: list comprehension ==")
;; `for` generates a lazy sequence — think "for every x, give me f(x)"
(println (for [x (range 5)] (* x x)))
(println)
(println "== multiple bindings ==")
;; Multiple bindings = nested loop; the RIGHTMOST binding varies fastest
(println (for [x [:a :b :c] y [1 2]] [x y]))
(println)
(println "== chess board coordinates ==")
;; Classic list-comp use-case: a Cartesian grid
(def files [:a :b :c :d :e :f :g :h])
(def ranks (range 1 9))
(def board (for [f files r ranks] [f r]))
(println (count board))
(println (take 8 board))
(println)
(println "== :when filters ==")
;; :when drops bindings that don't match — like filter, inline
(println (for [x (range 10) :when (odd? x)] x))
;; Dark squares only — (file + rank) is odd
(def file-index {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7 :h 8})
(def dark-squares
(for [f files r ranks
:when (odd? (+ (file-index f) r))]
[f r]))
(println (count dark-squares))
(println (take 4 dark-squares))
(println)
(println "== :let bindings ==")
;; :let names intermediate values so downstream :when / body stay clean
(def pythagorean-triples
(for [a (range 1 20)
b (range a 20)
:let [c2 (+ (* a a) (* b b))
c (Math/sqrt c2)]
:when (= c2 (* (int c) (int c)))]
[a b (int c)]))
(println pythagorean-triples)
(println)
(println "== doseq: for side effects ==")
;; Same shape as `for`, but returns nil and runs eagerly
;; Use it when you want the effects (println, writes) — not a collection
(doseq [n (range 1 4)]
(println "tick" n))
(println)
(println "== multiplication table with doseq ==")
;; doseq with multiple bindings — two nested loops, no collection built
(doseq [x (range 1 6)]
(doseq [y (range 1 6)]
(print (format "%3d " (* x y))))
(println))
(println)
(println "== for vs doseq — the rule ==")
;; for => returns a lazy sequence of results (value-producing)
;; doseq => returns nil, runs eagerly (effect-producing)
(def squares (for [x (range 5)] (* x x)))
(println "for =>" squares)
(def nothing (doseq [x (range 5)] (print x " ")))
(println)
(println "doseq =>" nothing)
The three modifiers, in plain English
:when filters bindings. Drops any combination where the predicate returns false. Behaves like filter at that nesting level — if it's before a binding, the binding is skipped entirely; if it's after the last binding, only the body is skipped.
:let names intermediate values. Lets you compute something once per binding combination and use it both in subsequent :when conditions and in the body. The Pythagorean-triples example above relies on this — c2 and c are computed once per (a, b) pair, then both checked and emitted.
:while stops the comprehension. The less-common third modifier. Like take-while — as soon as the predicate is false, the comprehension stops returning values for that nesting level. Useful for sorted inputs where you can short-circuit.
The for vs doseq decision
This is the only choice you have to make, and the rule is one sentence: if you want the result, use for; if you want the effects, use doseq.
for | doseq | |
|---|---|---|
| Returns | A lazy sequence of results | nil |
| Evaluation | Lazy — body runs only when results are consumed | Eager — body runs immediately, every iteration |
| Use for | Computing values: maps, vectors, sequences to pass on | Side effects: println, file writes, DB inserts |
| Memory | Holds onto the head if bound to a name (chunked) | Releases each iteration immediately |
The pitfall: writing (for [x xs] (println x)) at the REPL and wondering why nothing prints. for is lazy — without something forcing the sequence (doall, a println on the result, an into), the body never runs. doseq is the explicit fix: I want the effect, I don't care about the value.
When list comprehensions are not the answer
Two cases where you should reach past for for something else:
Single-binding maps over an existing collection. (for [x xs] (f x)) works, but (map f xs) reads better. for earns its keep when you have multiple bindings or modifiers; otherwise map / filter is the idiomatic call.
You need to terminate the iteration based on accumulated state. for can't see prior iterations. If you need a running sum, a sliding window, or any kind of state that crosses iterations, switch to reduce or loop/recur. Trying to fake state with closures inside for is fighting the language.
One more pattern: doall + for for forced eagerness
Sometimes you want for's shape but you need it to run now — for example, to catch exceptions inside a try block, or to fully realize a sequence before a database connection closes. The standard idiom is (doall (for [...] ...)): build the sequence with for, then force it eagerly with doall. The result is a fully-realized seq with the effects of every iteration applied.
The takeaway
Three things to internalize from this episode:
- Multiple bindings = Cartesian product, rightmost varies fastest. Picture a chess board: outer loop is the file, inner loop is the rank.
:whenfilters,:letnames intermediate values,:whilestops short. Three modifiers, three jobs. Each goes in the binding vector.forfor values (lazy),doseqfor effects (eager). If you find yourself writing(doall (for ...))just for side effects, you wanteddoseq.
List comprehensions are a small piece of Clojure, but they're one of the moments where the language's "describe the answer, not the steps" philosophy clicks. Once you start seeing problems as shapes of data rather than loops to write, the rest of Clojure's collection vocabulary (map, filter, reduce, into, transduce) reads like the same idea applied at different scales.
Watch the video above for a full walkthrough — every keystroke is shown so you can code along.
Student code: GitHub