A CSV reader and writer for Common Lisp, conforming to RFC 4180 and with support for RFC 7111 URI fragment identifiers.
(ql:quickload :io.github.cl-sdk.csv)The test suite uses the FiveAM framework.
;; From the REPL, with the project root in ASDF's search path:
(ql:quickload :io.github.cl-sdk.csv.test)
(fiveam:run-all-tests)Or from a shell:
sbcl --noinform \
--eval '(require :asdf)' \
--eval '(push #P"/path/to/cl-csv/" asdf:*central-registry*)' \
--eval '(asdf:load-system :io.github.cl-sdk.csv.test)' \
--eval '(unless (fiveam:run-all-tests) (uiop:quit -1))' \
--eval '(uiop:quit)'Build a standalone executable:
make cliThis produces csv-to-list in the current directory.
Usage:
./cl-csv-dump data.csv
cat data.csv | ./cl-csv-dump
./cl-csv-dump -
./cl-csv-dump --no-header data.csv # treat file as having no header| Variable | Default | Description |
|---|---|---|
*separator* |
#\, |
Field-separator character |
*quote* |
#\" |
Quoting character |
*newline* |
"\r\n" |
Row terminator written on output (RFC 4180 mandates CRLF) |
*always-quote* |
nil |
When non-nil, every output field is quoted |
Read one row from a stream:
(with-input-from-string (s "a,b,c")
(io.github.cl-sdk.csv:read-csv-row s))
; => ("a" "b" "c");; Default: file has a header — header returned as second value, excluded from rows
(io.github.cl-sdk.csv:read-csv "name,age
Alice,30
Bob,25")
; primary => (("Alice" "30") ("Bob" "25"))
; secondary => ("name" "age")
;; Convenient destructuring with multiple-value-bind
(multiple-value-bind (rows header)
(io.github.cl-sdk.csv:read-csv "name,age
Alice,30
Bob,25")
(format t "Header: ~a~%" header)
(format t "Data: ~a~%" rows))
; Header: (name age)
; Data: ((Alice 30) (Bob 25))
;; File has no header — second value is nil, all rows are data
(multiple-value-bind (rows header)
(io.github.cl-sdk.csv:read-csv "Alice,30
Bob,25" :has-header nil)
(format t "Header: ~a~%" header)
(format t "Data: ~a~%" rows))
; Header: nil
; Data: ((Alice 30) (Bob 25))Stream rows as events instead of materializing the full table:
(io.github.cl-sdk.csv:parse-csv "name,age
Alice,30
Bob,25"
(lambda (event payload)
(format t "~a => ~s~%" event payload)))
;; :BEGIN-DOCUMENT => NIL
;; :HEADER => ("name" "age")
;; :LINE => ("Alice" "30")
;; :LINE => ("Bob" "25")
;; :END-DOCUMENT => NILOmit the parser argument to use the default collecting parser:
(multiple-value-bind (rows header)
(io.github.cl-sdk.csv:parse-csv "name,age
Alice,30
Bob,25")
(list rows header))
; => ((("Alice" "30") ("Bob" "25")) ("name" "age"))Custom parser implementations can subclass io.github.cl-sdk.csv:csv-parser and define the
event callbacks they care about:
(defclass counting-parser (io.github.cl-sdk.csv:csv-parser)
((lines :initform 0 :accessor lines)))
(defmethod io.github.cl-sdk.csv:csv-parser-line ((parser counting-parser) row)
(declare (ignore row))
(incf (lines parser)))
(defmethod io.github.cl-sdk.csv:csv-parser-result ((parser counting-parser))
(lines parser))
(io.github.cl-sdk.csv:parse-csv "name,age
Alice,30
Bob,25"
(make-instance 'counting-parser))
; => 2Write a single row:
(io.github.cl-sdk.csv:write-csv-row '("Alice" "30") *standard-output*)
; prints: Alice,30\r\nWrite all rows to output:
nil→ returns the CSV as a fresh stringt→ writes to*standard-output*- stream → writes to that stream
- pathname → writes to file (UTF-8, overwrites if exists)
;; No header (default) — rows are plain data
(io.github.cl-sdk.csv:write-csv '(("Alice" "30") ("Bob" "25")) nil)
; => "Alice,30\r\nBob,25\r\n"
;; With a header passed explicitly
(io.github.cl-sdk.csv:write-csv '(("Alice" "30") ("Bob" "25")) nil
:headers '("name" "age"))
; => "name,age\r\nAlice,30\r\nBob,25\r\n"Force quoting:
(io.github.cl-sdk.csv:write-csv '(("a" "b")) nil :always-quote t)
; => "\"a\",\"b\"\r\n"Use tab as separator (TSV):
(io.github.cl-sdk.csv:write-csv '(("a" "b")) nil :separator #\Tab)
; => "a\tb\r\n"RFC 7111 defines URI fragment identifiers for selecting subsets of a
text/csv resource:
http://example.com/data.csv#row=1,3-5
http://example.com/data.csv#col=2-4
http://example.com/data.csv#cell=1-2,3-4
Parse RFC 7111 fragment strings:
(io.github.cl-sdk.csv:parse-fragment "row=1,3-5")
; => ((:TYPE :ROW :POSITIONS ((1 . 1) (3 . 5))))
(io.github.cl-sdk.csv:parse-fragment "col=2-4;row=1")
; => ((:TYPE :COL :POSITIONS ((2 . 4)))
; (:TYPE :ROW :POSITIONS ((1 . 1))))Apply fragment identifiers to parsed CSV tables (row and column numbers are 1-based):
(defvar *table*
'(("name" "age" "city")
("Alice" "30" "NY")
("Bob" "25" "LA")
("Carol" "35" "SF")))
;; Select rows 1 and 3
(io.github.cl-sdk.csv:select-by-fragment *table* "row=1,3")
; => (("Alice" "30" "NY") ("Carol" "35" "SF"))
;; Select column 1 (name) for all rows, keeping the header
(io.github.cl-sdk.csv:select-by-fragment *table* "col=1" :include-header t)
; => (("name") ("Alice") ("Bob") ("Carol"))
;; Select a specific cell range
(io.github.cl-sdk.csv:select-by-fragment *table* "cell=1-1,2-2")
; => (("Alice" "" "") ("" "25" ""))The registered MIME type for CSV is text/csv (RFC 4180 §3).
Relevant parameters:
| Parameter | Description |
|---|---|
charset |
Character encoding; defaults to US-ASCII per RFC 2046 §4.1.2 |
header |
present if the first record is a header row, absent otherwise |
Example Content-Type header:
Content-Type: text/csv; charset=UTF-8; header=present
See LICENSE.