Part of Clojure for Beginners

Clojure for Beginners: Clojure `for`, `doseq` & List Comprehensions — Generate and Iterate with Clarity

Celest KimCelest Kim

Video: Clojure `for`, `doseq` & List Comprehensions — Generate and Iterate with Clarity | Episode 19 by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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.

fordoseq
ReturnsA lazy sequence of resultsnil
EvaluationLazy — body runs only when results are consumedEager — body runs immediately, every iteration
Use forComputing values: maps, vectors, sequences to pass onSide effects: println, file writes, DB inserts
MemoryHolds 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:

  1. Multiple bindings = Cartesian product, rightmost varies fastest. Picture a chess board: outer loop is the file, inner loop is the rank.
  2. :when filters, :let names intermediate values, :while stops short. Three modifiers, three jobs. Each goes in the binding vector.
  3. for for values (lazy), doseq for effects (eager). If you find yourself writing (doall (for ...)) just for side effects, you wanted doseq.

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

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.