Skip to content

LISP - File I/O

File input/output (I/O) in Common Lisp is handled through an abstraction called streams. A stream is a source or sink of data, which can be a file, the terminal, a network connection, or even a string. The same functions for reading and writing data, like format and read-line, can work on any type of stream.

This chapter covers the modern, safe, and idiomatic way to perform file operations in Common Lisp.

While Lisp provides a low-level open function, the recommended best practice is to always use the with-open-file macro. It automates the crucial step of closing the file, even if an error occurs during processing. This prevents resource leaks and is a core principle of robust programming.

The syntax for with-open-file is:

(with-open-file (stream-variable filename-or-path &key options...)
;; Code to read from or write to the stream-variable goes here
...)
;; The file is automatically closed here

Key options for with-open-file include:

  • :direction: Specifies the stream’s purpose. Common values are: * :input (default): For reading from a file. * :output: For writing to a file. * :io: For both reading and writing.
  • :if-exists: What to do if :direction is :output and the file already exists. * :supersede (default): Overwrite the existing file. * :append: Add new content to the end of the file. * :error: Signal an error.
  • :if-does-not-exist: What to do if the file doesn’t exist. * :create (default for :output): Create a new file. * :error (default for :input): Signal an error. * nil: Return nil from with-open-file instead of signaling an error.
  • :element-type: The type of data being transferred. Defaults to character for text files. For binary files, use (unsigned-byte 8).
  • :external-format: The character encoding. It’s good practice to specify this explicitly. A modern standard is :utf-8.

This example writes several lines of text to a file named notes.txt. If the file exists, it will be overwritten.

(defun write-notes ()
(with-open-file (stream "notes.txt"
:direction :output
:if-exists :supersede
:external-format :utf-8)
(format stream "Welcome to a modern Lisp tutorial!~%")
(format stream "File I/O is managed safely with with-open-file.~%")
(format stream "The file stream is automatically closed.~%"))
(format t "File 'notes.txt' has been written successfully.~%"))
(write-notes)

After running this code, a file named notes.txt will be created in the same directory with the specified content. The ~% in format is a convenient way to print a newline character.

Now, let’s read the content back from notes.txt. We will read the file line-by-line until we reach the end.

(defun read-notes ()
(with-open-file (stream "notes.txt" :if-does-not-exist nil)
(if stream
(loop for line = (read-line stream nil)
while line
do (format t "Read line: ~a~%" line))
(format t "File 'notes.txt' does not exist.~%"))))
(read-notes)

In this example:

  • :if-does-not-exist nil prevents an error if the file is missing; with-open-file will simply return nil.
  • (read-line stream nil) reads one line of text. The second argument, nil, tells it to return nil at the end of the file instead of signaling an error. This nil then causes the loop’s while condition to fail, gracefully terminating the loop.

If you run this after the writing example, the output will be:

Read line: Welcome to a modern Lisp tutorial!
Read line: File I/O is managed safely with with-open-file.
Read line: The file stream is automatically closed.

Common Lisp has a sophisticated, platform-independent way to handle file paths called pathnames. While strings like "notes.txt" often work, using the pathname constructor #p can be more robust, especially for complex paths.

;; A more robust way to specify the file path
(with-open-file (stream #p"notes.txt" :direction :input)
...)

Using pathnames allows Lisp to handle directory separators and other platform-specific details automatically.