Beginner

Control Flow in Clojure

Learn control flow in Clojure - conditionals with if/when/cond/case, recursion, loop/recur, and sequence-driven iteration with Docker-ready examples

Control flow is how a program decides what to do next. In most languages this means statements that execute for their side effects—if blocks, for loops, switch cases. Clojure approaches control flow differently: as a functional Lisp, almost everything is an expression that returns a value, and there are no statements to sequence.

Because Clojure favors immutability, it doesn’t lean on counter-based loops that mutate a variable. Instead, iteration is expressed through recursion, the loop/recur construct, and—most idiomatically—through sequence operations that transform whole collections at once. Conditionals like if, when, cond, and case are themselves expressions: they evaluate to a value you can bind, return, or pass along.

In this tutorial you’ll learn how Clojure expresses branching with if and its friends, how to choose between many cases with cond and case, and how to iterate without mutable loop counters using loop/recur, dotimes, and doseq. Along the way you’ll see why thinking in expressions and sequences—not statements—is the heart of writing idiomatic Clojure.

Conditionals: if, when, if-not

The fundamental conditional is if. It takes a test, a “then” expression, and an optional “else” expression. Crucially, if returns a value—it isn’t a block of statements.

In Clojure, only false and nil are falsey; everything else (including 0, "", and empty collections) is truthy.

Create a file named conditionals.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
;; if returns a value - both branches are expressions
(def temperature 18)

(println (if (> temperature 25)
           "It's warm"
           "It's cool"))

;; if with no else branch returns nil when the test is false
(println (if (< temperature 0) "Freezing!"))

;; when is "if with no else" - useful for side effects when true
(when (< temperature 20)
  (println "Grab a jacket")
  (println "Maybe a scarf too"))

;; when-not is the inverse - body runs only when the test is false
(when-not (zero? temperature)
  (println "Temperature is non-zero"))

;; In Clojure only false and nil are falsey - 0 and "" are truthy
(println (if 0 "zero is truthy" "zero is falsey"))
(println (if "" "empty string is truthy" "empty string is falsey"))
(println (if nil "nil is truthy" "nil is falsey"))

when is preferred over if when there is no else branch and you have multiple expressions to evaluate—its body has an implicit do, so all expressions run in order and the last one is returned.

Choosing Among Many: cond and case

When you have more than two branches, nesting if expressions gets unwieldy. Clojure provides cond for arbitrary test conditions and case for matching against constant values.

Create a file named multi_branch.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
;; cond tests each condition in order, returning the first match's value.
;; :else is just a truthy keyword used as the default catch-all.
(defn grade [score]
  (cond
    (>= score 90) "A"
    (>= score 80) "B"
    (>= score 70) "C"
    (>= score 60) "D"
    :else         "F"))

(println "Score 95 ->" (grade 95))
(println "Score 83 ->" (grade 83))
(println "Score 71 ->" (grade 71))
(println "Score 42 ->" (grade 42))

;; case compares against compile-time constants (fast dispatch).
;; The trailing value (with no test) is the default.
(defn describe-day [day]
  (case day
    (:saturday :sunday) "Weekend"
    :monday             "Start of the week"
    (:tuesday :wednesday :thursday :friday) "Weekday"
    "Unknown day"))

(println "Saturday ->" (describe-day :saturday))
(println "Monday   ->" (describe-day :monday))
(println "Wednesday ->" (describe-day :wednesday))
(println "Funday   ->" (describe-day :funday))

Use case when you’re matching against fixed literal values—it’s faster because it dispatches in constant time. Use cond when each branch needs a different test expression (ranges, predicates, multiple variables).

Iteration with loop and recur

Clojure has no mutable loop counter. For explicit iteration, use loop to establish initial bindings and recur to jump back to the loop with new values. recur performs tail-call optimization, so this won’t blow the stack even for large counts.

Create a file named recursion.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
;; loop/recur: factorial computed with an accumulator.
;; recur rebinds n and acc and jumps back to loop - no stack growth.
(defn factorial [n]
  (loop [n   n
         acc 1]
    (if (zero? n)
      acc
      (recur (dec n) (* acc n)))))

(println "5! =" (factorial 5))
(println "10! =" (factorial 10))

;; recur also works to re-enter a function directly (tail position).
(defn count-down [n]
  (when (pos? n)
    (println "T-minus" n)
    (recur (dec n))))

(count-down 3)
(println "Liftoff!")

;; Recursion without recur is fine for shallow, non-tail calls.
(defn fib [n]
  (if (< n 2)
    n
    (+ (fib (- n 1)) (fib (- n 2)))))

(println "fib(10) =" (fib 10))

The accumulator pattern in factorial—carrying the running result as a loop binding—is the idiomatic functional alternative to mutating a variable inside a for loop.

Sequence-Driven Iteration: doseq and dotimes

Most real Clojure code doesn’t write explicit loops at all. To iterate over a collection for side effects (like printing), use doseq. To repeat something a fixed number of times, use dotimes. Both return nil—they exist for their side effects.

Create a file named sequences.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
;; doseq iterates over a collection, binding each element in turn.
(doseq [fruit ["apple" "banana" "cherry"]]
  (println "Fruit:" fruit))

;; dotimes repeats a body n times, binding an index from 0 to n-1.
(dotimes [i 3]
  (println "Iteration" i))

;; doseq with :when acts as a filter, and supports multiple bindings.
(doseq [n (range 1 11)
        :when (even? n)]
  (println n "is even"))

;; The functional way: transform the whole sequence, no explicit loop.
;; map applies a function to each element; filter keeps matching ones.
(def numbers (range 1 6))
(println "Squares:" (map (fn [x] (* x x)) numbers))
(println "Evens:"   (filter even? numbers))
(println "Sum:"     (reduce + numbers))

The last block shows the most idiomatic Clojure style: rather than looping and accumulating manually, you describe a transformation of the data with map, filter, and reduce. This is the functional mindset—operate on collections as a whole rather than stepping through them element by element.

Running with Docker

You can run every example above without installing Clojure locally.

1
2
3
4
5
6
7
8
# Pull the official Clojure image
docker pull clojure:latest

# Run each example
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure conditionals.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure multi_branch.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure recursion.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure sequences.clj

Expected Output

Running conditionals.clj:

It's cool
nil
Grab a jacket
Maybe a scarf too
Temperature is non-zero
zero is truthy
empty string is truthy
nil is falsey

Running multi_branch.clj:

Score 95 -> A
Score 83 -> B
Score 71 -> C
Score 42 -> F
Saturday -> Weekend
Monday   -> Start of the week
Wednesday -> Weekday
Funday   -> Unknown day

Running recursion.clj:

5! = 120
10! = 3628800
T-minus 3
T-minus 2
T-minus 1
Liftoff!
fib(10) = 55

Running sequences.clj:

Fruit: apple
Fruit: banana
Fruit: cherry
Iteration 0
Iteration 1
Iteration 2
2 is even
4 is even
6 is even
8 is even
10 is even
Squares: (1 4 9 16 25)
Evens: (2 4)
Sum: 15

Key Concepts

  • Conditionals are expressionsif, when, cond, and case all return values, so you can bind their result, return it, or pass it to another function.
  • Only false and nil are falsey — unlike many languages, 0, "", and empty collections are all truthy in Clojure.
  • when vs if — use when (and when-not) when there’s no else branch and you have one or more side-effecting expressions; its body has an implicit do.
  • cond for predicates, case for constantscond evaluates arbitrary test expressions top to bottom; case does fast constant-time dispatch on literal values.
  • No mutable loop counters — iteration uses loop/recur with an accumulator instead of mutating a variable; recur is tail-call optimized and won’t overflow the stack.
  • doseq and dotimes are for side effects — both return nil; use doseq to walk a collection (with optional :when filters) and dotimes to repeat a fixed number of times.
  • Prefer transformations over loops — the most idiomatic control flow is often map, filter, and reduce, which operate on whole sequences rather than stepping through them imperatively.

Running Today

All examples can be run using Docker:

docker pull clojure:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining