Intermediate

I/O Operations in Clojure

Learn console and file I/O in Clojure - printing, reading input, slurp/spit, line-by-line reading, and I/O error handling with Docker-ready examples

Input and output are how a program talks to the outside world: printing results, reading what a user types, and persisting data to files. In Hello World you saw println, but that is only the beginning of what Clojure offers.

Because Clojure is a functional language hosted on the JVM, its approach to I/O has two distinct flavors. On one hand, Clojure provides beautifully terse functions like slurp and spit that read or write an entire file in a single expression. On the other hand, I/O is inherently a side effect — it is not pure — so Clojure keeps these operations explicit and often pairs them with with-open to manage resources safely and try/catch to handle failures.

This tutorial covers writing to the console (beyond println), reading from standard input, reading and writing files, processing files line-by-line with lazy sequences, and handling I/O errors gracefully. Every example is a self-contained script you can run directly with the Clojure Docker image.

Console Output Beyond println

Clojure offers several printing functions, each with a purpose. println and print produce human-readable text, while prn and pr produce machine-readable (round-trippable) output where strings keep their quotes. For structured formatting, format builds a string with Java-style format specifiers.

Create a file named output.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
;; Console output in Clojure

;; println adds a trailing newline
(println "Written with println")

;; print does not add a newline
(print "No newline -> ")
(println "same line")

;; prn/pr print values in read-able form (strings keep their quotes)
(prn "quoted")
(prn [1 2 3])

;; println accepts multiple arguments, separated by spaces
(println "Sum of 2 and 3 is" (+ 2 3))

;; format returns a formatted string (Java-style format specifiers)
(println (format "Name: %s, Age: %d" "Ada" 36))
(println (format "Pi is about %.2f" Math/PI))

;; str concatenates its arguments into a single string
(println (str "abc" 1 2 3))

The distinction between println and prn matters: println is for people, prn is for data you might read back into Clojure later.

Reading Console Input

The read-line function reads a single line from standard input and returns it as a string (or nil at end-of-file). To keep this example reproducible when run non-interactively, we use with-in-str, which temporarily binds standard input to a string. In a real interactive program you would call read-line directly.

Create a file named input.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
;; Reading console input in Clojure

;; read-line reads one line from stdin as a string.
;; with-in-str feeds it fixed input so the example is reproducible.
(with-in-str "Grace\n42\n"
  (let [name (read-line)
        age  (read-line)]
    (println (str "Hello, " name "!"))
    (println (str "Next year you will be "
                  (inc (Integer/parseInt age))
                  "."))))

;; In a real interactive program you would prompt and read directly:
;;   (print "Enter your name: ")
;;   (flush)                       ; force the prompt to appear before reading
;;   (let [name (read-line)]
;;     (println (str "Hello, " name "!")))

Note the flush in the commented example: print is buffered, so without flushing, an interactive prompt might not appear before the program waits for input.

Writing Files with spit

Clojure’s spit writes a string to a file in one call, creating the file or overwriting it. Passing :append true adds to an existing file instead. This terseness is idiomatic Clojure — common operations should be one expression.

Create a file named file_write.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
;; Writing files in Clojure
(require '[clojure.string :as string])

;; spit writes a string to a file (creating or overwriting it)
(spit "greetings.txt" "Hello from Clojure!\n")

;; :append true adds to the file instead of overwriting it
(spit "greetings.txt" "A second line.\n" :append true)

;; Build content from a data structure, then write it
(let [lines ["Alpha" "Beta" "Gamma"]]
  (spit "letters.txt" (string/join "\n" lines)))

(println "Files written.")

;; Read them back to confirm
(print (slurp "greetings.txt"))
(println "---")
(println (slurp "letters.txt"))

Notice how slurp is the mirror image of spit: it reads an entire file into a single string.

Reading Files Line by Line

While slurp is perfect for small files, large files are better processed lazily one line at a time. Clojure’s line-seq returns a lazy sequence of lines from a reader, and with-open guarantees the reader is closed afterward — even if an exception is thrown mid-way. This is the functional, resource-safe way to stream data.

Create a file named file_read.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
;; Reading files in Clojure
(require '[clojure.java.io :as io])

;; Set up a sample file to read
(spit "poem.txt" "roses are red\nviolets are blue\nClojure is\nfunctional too\n")

;; slurp reads the whole file into one string
(println "Whole file with slurp:")
(println (slurp "poem.txt"))

;; For large files, stream line-by-line with a lazy sequence.
;; with-open closes the reader automatically.
(println "Line by line:")
(with-open [rdr (io/reader "poem.txt")]
  (doseq [line (line-seq rdr)]
    (println (str "> " line))))

;; line-seq composes with any sequence function, like count
(with-open [rdr (io/reader "poem.txt")]
  (println "Total lines:" (count (line-seq rdr))))

Because line-seq is lazy, you can pipe it through map, filter, reduce, and other sequence functions without loading the whole file into memory.

Handling I/O Errors

I/O can fail: files go missing, disks fill up, permissions are wrong. Clojure uses the JVM’s exception mechanism through try/catch/finally. You can catch specific Java exception classes and provide a fallback, and finally runs cleanup code regardless of success or failure.

Create a file named io_errors.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
;; I/O error handling in Clojure

;; Wrap risky I/O in try/catch. slurp throws if the file is missing.
(defn read-safely [path]
  (try
    (slurp path)
    (catch java.io.FileNotFoundException _
      (str "[missing file: " path "]"))
    (catch Exception e
      (str "[error: " (.getMessage e) "]"))))

;; This file exists...
(spit "data.txt" "important data")
(println (read-safely "data.txt"))

;; ...this one does not
(println (read-safely "does-not-exist.txt"))

;; finally always runs — ideal for cleanup or logging
(try
  (println "Working with resources...")
  (finally
    (println "Cleanup always runs.")))

Catching a narrow exception type (FileNotFoundException) before the broad one (Exception) lets you give precise, useful error messages while still having a catch-all safety net.

Running with Docker

You can run every example with the official Clojure image — no local install required.

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

# Run each example
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure output.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure input.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure file_write.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure file_read.clj
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure io_errors.clj

The -v $(pwd):/app volume mount means files written by spit (like greetings.txt and poem.txt) appear in your current directory after the container exits.

Expected Output

Running output.clj:

Written with println
No newline -> same line
"quoted"
[1 2 3]
Sum of 2 and 3 is 5
Name: Ada, Age: 36
Pi is about 3.14
abc123

Running input.clj:

Hello, Grace!
Next year you will be 43.

Running file_write.clj:

Files written.
Hello from Clojure!
A second line.
---
Alpha
Beta
Gamma

Running file_read.clj:

Whole file with slurp:
roses are red
violets are blue
Clojure is
functional too

Line by line:
> roses are red
> violets are blue
> Clojure is
> functional too
Total lines: 4

Running io_errors.clj:

important data
[missing file: does-not-exist.txt]
Working with resources...
Cleanup always runs.

Key Concepts

  • slurp and spit are the workhorses — read or write an entire file in a single expression; spit takes :append true to add rather than overwrite.
  • Choose your print function by intentprintln/print for humans, prn/pr for machine-readable data that round-trips back into Clojure, and format for structured, specifier-based output.
  • I/O is a side effect — unlike Clojure’s pure functions, I/O interacts with the outside world, so these operations are kept explicit and are ordered by evaluation.
  • with-open guarantees cleanup — it closes readers and writers automatically, even when an exception is thrown, making resource leaks nearly impossible.
  • line-seq is lazy — process arbitrarily large files line-by-line, composing with map, filter, and reduce without loading everything into memory.
  • read-line returns a string or nil — remember to flush after a print-based prompt so it appears before input is read.
  • Error handling uses JVM exceptionstry/catch/finally lets you catch specific classes like java.io.FileNotFoundException and provide fallbacks, with finally for cleanup that always runs.

Running Today

All examples can be run using Docker:

docker pull clojure:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining