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.
(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
1. Why is with-open-file preferred over a manual (open ...) / (close ...) pair?
2. What is the key difference between read-line and read on a text file stream?
3. What element-type must a stream use for correct binary file I/O?
4. Why should *read-eval* be bound to nil when reading untrusted files with read?
5. Which :if-exists value causes with-open-file to overwrite an existing file's contents entirely?
Was this page helpful?
You May Also Like
Error Handling and Conditions
How Common Lisp's condition system goes beyond try/catch, letting programs signal, inspect, and interactively recover from errors using restarts.
Quicklisp and Package Management
How Quicklisp and ASDF work together to define, load, and distribute Common Lisp systems and their dependencies.
LISP for AI and Symbolic Computing
Why LISP became the historical language of AI research, and how homoiconicity, symbolic data, and macros support symbolic reasoning, search, and rule-based systems.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics