Intermediate

I/O Operations in Common Lisp

Learn console output, reading input, file reading and writing, formatted output, and I/O error handling in Common Lisp with Docker-ready examples

Input and output are where a program meets the outside world. In Common Lisp, all I/O flows through streams — first-class objects that represent a source of characters (or bytes) to read from, or a destination to write to. The terminal, a file, and even an in-memory string are all streams, and the same functions work across all of them.

Because Common Lisp is a multi-paradigm language with a functional heart, I/O is treated as a deliberate side effect. Functions like format, read-line, and write-line are explicit about where they read and write: they take (or default to) a stream argument. This tutorial goes beyond the single format call you saw in Hello World and covers console output, reading input, working with files, formatted output, and handling I/O errors with the condition system.

The idiomatic tool for output is format, a miniature language for producing text. For file work, the with-open-file macro guarantees streams are closed even when errors occur — a clean, functional approach to resource management. Let’s explore each of these.

Console Output

Common Lisp offers several output functions, each with slightly different behavior. Knowing which to reach for keeps your output clean.

Create a file named output.lisp:

 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
;;;; Console output functions in Common Lisp

;; format with t writes to standard output; ~% is a newline
(format t "Standard formatted output~%")

;; princ prints a value without quotes and without a trailing newline
(princ "princ: no quotes")
(terpri)  ; terpri outputs a single newline

;; write-line prints a string followed by a newline
(write-line "write-line adds a newline")

;; print outputs a newline BEFORE the value, and adds quotes to strings
(print "print adds quotes")

;; ~a is aesthetic (human-readable), ~s is machine-readable (with quotes)
;; ~& is a "fresh line": a newline only if not already at the line start
(format t "~&~a vs ~s~%" "text" "text")

;; write-string writes characters with no added newline
(write-string "no newline here")
(terpri)

;; finish-output flushes buffered output to the stream
(finish-output)
  • princ prints a human-readable representation (no quotes) and adds nothing after it.
  • terpri (“terminate print”) emits one newline.
  • print is designed for debugging: it prints a machine-readable form preceded by a newline.
  • ~& (fresh-line) prevents accidental blank lines by only emitting a newline when needed.

Reading Input

To read from the terminal you use read-line (a whole line as a string) or read (one parsed Lisp object). So this example is fully reproducible, we read from an in-memory string stream instead of the keyboard — the reading functions behave identically whether the stream is a terminal, a file, or a string.

Create a file named input.lisp:

 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
;;;; Reading input in Common Lisp

;; In a real program you would read from the terminal like this:
;;   (read-line)                    ; one line as a string
;;   (parse-integer (read-line))    ; one line converted to an integer
;;   (read)                         ; one parsed Lisp object

;; For a reproducible example we read from a string stream. The same
;; read-line / read functions work on any input stream.
(let ((input (format nil "Ada Lovelace~%42~%3 4 5")))
  (with-input-from-string (in input)

    ;; read-line reads a full line as a string
    (let ((name (read-line in)))
      (format t "Name: ~a~%" name))

    ;; parse-integer converts a numeric string to an integer
    (let ((age (parse-integer (read-line in))))
      (format t "Next year you will be ~a~%" (1+ age)))

    ;; read parses one Lisp object at a time (here, three numbers)
    (let ((x (read in))
          (y (read in))
          (z (read in)))
      (format t "Sum: ~a~%" (+ x y z)))))

The key distinction: read-line gives you raw text, while read parses the input using the Lisp reader, turning 3 into the number 3. When you need numbers from text, parse-integer (and read-from-string) bridge the gap.

Reading and Writing Files

The with-open-file macro is the workhorse of file I/O. It opens a stream, binds it to a variable, runs your code, and guarantees the stream is closed afterward — even if an error is signaled. This example writes a file and then reads it back.

Create a file named file_io.lisp:

 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
29
30
;;;; File input and output in Common Lisp

(defparameter *filename* "poem.txt")

;; Writing: :if-exists :supersede overwrites any existing file,
;; :if-does-not-exist :create makes a new one.
(with-open-file (out *filename*
                     :direction :output
                     :if-exists :supersede
                     :if-does-not-exist :create)
  (write-line "Roses are red" out)
  (write-line "Violets are blue" out)
  (format out "Lisp has parentheses~%")
  (format out "And so do you~%"))

(format t "Wrote data to ~a~%" *filename*)

;; Reading line by line. read-line returns its second argument (nil)
;; at end of file instead of signaling an error.
(format t "--- File contents ---~%")
(with-open-file (in *filename* :direction :input)
  (loop for line = (read-line in nil nil)
        while line
        do (format t "~a~%" line)))

;; Reading the whole file into one string with read-sequence.
(with-open-file (in *filename* :direction :input)
  (let ((contents (make-string (file-length in))))
    (read-sequence contents in)
    (format t "--- Character count: ~a ---~%" (length contents))))

Notice the loop for line = (read-line in nil nil) while line idiom — this is the canonical way to iterate over a file’s lines. The two nil arguments to read-line mean “at end of file, return nil rather than raising an error,” which lets the while clause stop the loop cleanly.

Formatted Output

The format function is a small text-generation language of its own. Its directives handle padding, number bases, pluralization, and iteration over lists — tasks that would need several lines in other languages.

Create a file named formatted.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
;;;; The format function's directive mini-language

;; ~10a pads a value to 10 columns, left-justified
(format t "~10a~a~%" "Name" "Score")
(format t "~10a~a~%" "Alice" 95)
(format t "~10a~a~%" "Bob" 87)

;; Number formatting
(format t "Pi to 4 places: ~,4f~%" 3.14159265)
(format t "Hex: ~x  Octal: ~o  Binary: ~b~%" 255 255 255)
(format t "Padded number: ~5,'0d~%" 42)

;; ~r spells numbers; ~@r produces Roman numerals
(format t "In English: ~r~%" 42)
(format t "In Roman: ~@r~%" 2026)

;; ~{ ~} iterates over a list; ~^ skips the separator after the last item
(format t "Languages: ~{~a~^, ~}~%" '("Lisp" "Scheme" "Clojure"))

;; ~:p prints "s" unless the preceding number was 1
(format t "~d item~:p~%" 1)
(format t "~d item~:p~%" 3)

A few highlights:

  • ~,4f — floating point rounded to 4 decimal places.
  • ~x / ~o / ~b — print integers in hexadecimal, octal, and binary.
  • ~5,'0d — decimal padded to width 5 using 0 as the pad character.
  • ~{...~} — loop over a list, with ~^ suppressing the trailing separator.
  • ~:p — automatic pluralization based on the previous argument.

Handling I/O Errors

File operations fail: a file may not exist, or a disk may be full. Common Lisp’s condition system handles these with handler-case, and stream functions can be told to return nil instead of signaling.

Create a file named errors.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
;;;; Handling I/O errors with the condition system

;; Opening a missing file signals a FILE-ERROR; handler-case recovers.
(handler-case
    (with-open-file (in "does-not-exist.txt" :direction :input)
      (format t "~a~%" (read-line in)))
  (file-error (e)
    (declare (ignore e))
    (format t "Could not open the file (handled the error)~%")))

;; Alternatively, :if-does-not-exist nil returns nil instead of erroring.
(let ((stream (open "also-missing.txt"
                    :direction :input
                    :if-does-not-exist nil)))
  (if stream
      (progn (format t "Opened the file~%") (close stream))
      (format t "File is missing, handled without an error~%")))

handler-case is the closest analog to try/catch, but the condition system is far more capable — it can inspect a condition, run restarts, and resume execution at the point of failure. For simple “does this file exist?” checks, the :if-does-not-exist nil option is often cleaner than catching a condition.

Running with Docker

Run each example with the official SBCL image. The -v $(pwd):/app flag mounts the current directory so SBCL can read your .lisp files (and so file_io.lisp can create poem.txt in your working directory).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Pull the official SBCL image
docker pull clfoundation/sbcl:latest

# Console output
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script output.lisp

# Reading input
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script input.lisp

# Reading and writing files
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script file_io.lisp

# Formatted output
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script formatted.lisp

# Handling I/O errors
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script errors.lisp

Expected Output

Running output.lisp:

Standard formatted output
princ: no quotes
write-line adds a newline

"print adds quotes"
text vs "text"
no newline here

Running input.lisp:

Name: Ada Lovelace
Next year you will be 43
Sum: 12

Running file_io.lisp:

Wrote data to poem.txt
--- File contents ---
Roses are red
Violets are blue
Lisp has parentheses
And so do you
--- Character count: 66 ---

Running formatted.lisp:

Name      Score
Alice     95
Bob       87
Pi to 4 places: 3.1416
Hex: FF  Octal: 377  Binary: 11111111
Padded number: 00042
In English: forty-two
In Roman: MMXXVI
Languages: Lisp, Scheme, Clojure
1 item
3 items

Running errors.lisp:

Could not open the file (handled the error)
File is missing, handled without an error

Key Concepts

  • Everything is a stream — the terminal, files, and in-memory strings all use the same read/write functions, so code written for one works with another.
  • format is a language — its directives (~a, ~d, ~,4f, ~{~}, ~:p, ~r) handle padding, number bases, iteration, and pluralization declaratively.
  • with-open-file guarantees cleanup — it closes the stream even if an error occurs, the functional answer to manual open/close bookkeeping.
  • read-line vs readread-line returns raw text; read parses input into Lisp objects. Use parse-integer to convert numeric strings.
  • The loop ... (read-line in nil nil) idiom — passing nil as the end-of-file value lets you iterate over lines without catching an end-of-file error.
  • I/O errors are conditions — recover with handler-case, or ask stream functions to return nil via options like :if-does-not-exist nil.
  • Fresh-line vs newline~& avoids duplicate blank lines by emitting a newline only when the cursor isn’t already at the start of a line.

Running Today

All examples can be run using Docker:

docker pull clfoundation/sbcl:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining