100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

File I/O in LISP

How Common Lisp reads and writes files using streams, with-open-file, and reader/printer control for both text and binary data.

Practical LISPBeginner9 min readJul 10, 2026
Analogies

Streams: The Foundation of All I/O

Every input and output operation in Common Lisp, whether it targets a file, the terminal, or an in-memory string, goes through a stream object. A stream is a uniform handle that functions like read, read-line, write, format, and print all operate on identically, which means code written against *standard-output* works unchanged when redirected to a file stream. Streams are directional (input, output, or bidirectional) and typed (character or binary/element-type), and the type you open a file with determines whether read-char and write-char or read-byte and write-byte are the correct operations to use on it.

🏏

Cricket analogy: A stream is like the broadcast feed itself: whether the footage goes to a stadium jumbotron, a TV network, or a phone app, the commentary and camera signal are produced the same way, just routed to a different output.

with-open-file and Automatic Cleanup

with-open-file is the idiomatic way to open a file because it guarantees the stream is closed even if the body signals a condition, using unwind-protect internally. Its keyword arguments control the contract precisely: :direction (:input, :output, :io), :if-exists (:supersede, :append, :error, nil), and :if-does-not-exist govern exactly what happens on each edge case, which avoids the ambiguity of a bare open/close pair where a forgotten close leaks a file descriptor or an unhandled error skips the close entirely.

🏏

Cricket analogy: It's like a match referee who guarantees the ground is properly closed and covered at the end of play no matter how the match ends — rain, a result, or an abandonment — rather than leaving it to whoever last walked off.

Reading Text vs Reading Lisp Forms

Common Lisp gives you two distinct reading vocabularies for a text file: read-line and read-char treat the file as raw text, returning strings and characters with no interpretation, while read invokes the Lisp reader itself, parsing the next well-formed s-expression into live data — symbols, numbers, lists, and strings. This matters because a configuration file written as Lisp data, like (:host "db1" :port 5432), can be loaded with a single (read stream) call into a working plist, whereas the same file read with read-line would just hand you a string you'd still have to parse yourself.

🏏

Cricket analogy: read-line is like reading the raw scoreboard numbers off the display, while read is like a scorer who understands cricket notation well enough to parse '4no' directly into a structured event of a boundary plus a no-ball.

lisp
(defun save-config (path config-plist)
  (with-open-file (out path :direction :output
                             :if-exists :supersede
                             :if-does-not-exist :create)
    (print config-plist out)))

(defun load-config (path)
  (with-open-file (in path :direction :input :if-does-not-exist :error)
    (read in)))

(defun copy-binary-file (source dest)
  (with-open-file (in source :direction :input
                              :element-type '(unsigned-byte 8))
    (with-open-file (out dest :direction :output
                               :element-type '(unsigned-byte 8)
                               :if-exists :supersede)
      (let ((buffer (make-array 4096 :element-type '(unsigned-byte 8))))
        (loop for bytes-read = (read-sequence buffer in)
              while (plusp bytes-read)
              do (write-sequence buffer out :end bytes-read))))))

Because read parses actual Lisp syntax, it will happily evaluate reader macros like #. at read time unless *read-eval* is bound to nil — always disable *read-eval* when reading untrusted files to avoid arbitrary code execution during a simple config load.

Opening a file with the default :element-type of character on a platform with a different default external-format than the file's actual encoding can silently corrupt non-ASCII text; pass :external-format explicitly (e.g. :utf-8) for any file whose encoding you can't guarantee.

  • All Lisp I/O goes through stream objects, so the same functions work for files, sockets, and strings.
  • with-open-file guarantees the stream is closed via unwind-protect, even if the body errors.
  • :direction, :if-exists, and :if-does-not-exist precisely control file-opening edge cases.
  • read-line and read-char treat a file as raw text; read parses it as Lisp syntax into live data.
  • Binary files require :element-type '(unsigned-byte 8) and read-sequence/write-sequence for efficient block I/O.
  • *read-eval* should be bound to nil when reading untrusted data with read.
  • Explicit :external-format avoids silent encoding corruption on non-ASCII text files.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#LISPStudyNotes#FileIOInLISP#File#LISP#Streams#Foundation#StudyNotes#SkillVeris#ExamPrep