Liking cljdoc? Tell your friends :D

clj-format

Clojars Project

A Clojure DSL for clojure.pprint/cl-format.

cl-format is extraordinarily powerful — it handles comma-grouped integers, Roman numerals, English number words, conditional pluralization, justified text, iteration with separators, and much more. But its format strings are notoriously hard to read:

(cl-format nil "~:{~:(~A~): ~:[baz~;~A~]~%~}" data)

clj-format lets you write the same thing as a Clojure data structure:

(clj-format nil
  [:each {:from :sublists}
    [:str {:case :capitalize}] ": " [:if :str "baz"] :nl]
  data)

When given a string, clj-format passes it directly to cl-format — full backward compatibility, zero migration cost.

See 50+ side-by-side examples from Practical Common Lisp, CLtL2, and the CL HyperSpec.

Quick Start

(require '[clj-format.core :as fmt])

;; String passthrough — identical to cl-format
(fmt/clj-format nil "~D item~:P" 5)
;; => "5 items"

;; DSL form — same result, readable syntax
(fmt/clj-format nil [:int " item" [:plural {:rewind true}]] 5)
;; => "5 items"

;; Parse a format string into the DSL
(fmt/parse-format "~R file~:P")
;; => [:cardinal " file" [:plural {:rewind true}]]

;; Compile DSL back to a format string
(fmt/compile-format [:cardinal " file" [:plural {:rewind true}]])
;; => "~R file~:P"

The DSL

The DSL follows the Hiccup convention: [:keyword optional-opts-map & body]. Bare keywords are shorthand for directives with no options. Strings are literal text. The complete DSL reference covers all 33 cl-format directives.

There are two common vector shapes:

  • A single directive vector like [:str] or [:int {:width 8}]
  • A body vector like ["Name: " :str] or [:cardinal " file" [:plural {:rewind true}]]

If the second element is a map, it is the options map. Otherwise the remaining elements are treated as body content. That means both :str and [:str] are valid ways to express ~A, depending on context.

Basics

;; Print values
:str                            ;; => ~A  (bare keyword shorthand)
[:str]                          ;; => ~A  (human readable)
[:pr]                           ;; => ~S  (readable with quotes)

;; Bare keywords in a body
["Name: " :str ", Age: " :int] ;; => "Name: ~A, Age: ~D"

;; Options in a map
[:int {:width 8 :fill \0}]     ;; => ~8,'0D
[:str {:width 20 :pad :left}]  ;; => ~20@A
[:char {:name true}]           ;; => ~:C   character name
[:char {:readable true}]       ;; => ~@C   readable char literal

Numbers

:int                                  ;; decimal
:bin :oct :hex                        ;; other bases
[:int {:group true}]                  ;; comma-grouped: 1,000,000
[:int {:sign :always}]                ;; always show sign: +42
[:hex {:width 4 :fill \0}]           ;; zero-padded hex: 00ff

:cardinal                             ;; "forty-two"
:ordinal                              ;; "forty-second"
:roman                                ;; "XLII"

[:float {:width 8 :decimals 2}]      ;; fixed-point
:money                                ;; monetary: 3.14

Iteration

;; Comma-separated list
[:each {:sep ", "} :str]                    ;; ~{~A~^, ~}

;; Iterate sublists
[:each {:from :sublists} :str ": " :int]    ;; ~:{~A: ~D~}

;; From remaining args
[:each {:sep ", " :from :rest} :str]        ;; ~@{~A~^, ~}

Conditionals

;; Boolean (true clause first)
[:if "yes" "no"]                            ;; ~:[no~;yes~]

;; Truthiness guard
[:when "value: " :str]                      ;; ~@[value: ~A~]

;; Numeric dispatch
[:choose "zero" "one" "two"]                ;; ~[zero~;one~;two~]
[:choose {:default "many"} "zero" "one"]    ;; ~[zero~;one~:;many~]

Case Conversion

Applied as a :case option — no extra nesting:

[:str {:case :capitalize}]                  ;; ~:(~A~) Capitalize Each Word
[:str {:case :upcase}]                      ;; ~:@(~A~) ALL CAPS
[:each {:sep ", " :case :capitalize} :str]  ;; capitalize a whole list

Pluralization

[:int " item" [:plural {:rewind true}]]     ;; "5 items" / "1 item"
[:int " famil" [:plural {:rewind true :form :ies}]]  ;; "1 family" / "2 families"

Layout

:nl                                   ;; newline
:fresh                                ;; newline only if not at column 0
[:tab {:col 20}]                      ;; tab to column 20
:tilde                                ;; literal ~

Navigation

:skip                                 ;; skip forward one arg
[:back {:n 2}]                        ;; back up two args
[:goto {:n 0}]                        ;; jump to arg 0

Real-World Examples

Date formatting

(clj-format nil [[:int {:width 4 :fill \0}] "-"
                 [:int {:width 2 :fill \0}] "-"
                 [:int {:width 2 :fill \0}]]
  2005 6 10)
;; => "2005-06-10"

Search results with grammar

;; "There are 3 results: 46, 38, 22"
;; "There is 1 result: 46"
(clj-format nil
  ["There " [:choose {:default "are"} "are" "is"] :back
   " " :int " result" [:plural {:rewind true}] ": "
   [:each {:sep ", "} :int]]
  n results)

XML tag formatter

(clj-format nil
  ["<" :str [:each :stop " " :str "=\"" :str "\""] [:if "/" nil] ">" :nl]
  "img" ["src" "cat.jpg" "alt" "cat"] true)
;; => "<img src=\"cat.jpg\" alt=\"cat\"/>\n"

Lowercase Roman numerals

(clj-format nil [:roman {:case :downcase}] 42)
;; => "xlii"

API

The public API is available from clj-format.core. The lower-level clj-format.parser and clj-format.compiler namespaces remain available, but clj-format.core re-exports parse-format and compile-format for convenience.

clj-format.core/clj-format

(clj-format writer fmt & args)

Drop-in replacement for clojure.pprint/cl-format.

writer — output destination:

  • nil or false — return formatted string
  • true — print to *out*, return nil
  • a java.io.Writer — write to it, return nil

fmt — format specification:

  • string — passed directly to cl-format (full backward compatibility)
  • vector — compiled from DSL to a format string, then passed to cl-format
  • keyword — shorthand for a single bare directive (e.g., :str for ~A)
(fmt/clj-format nil "~D item~:P" 5)                            ;; => "5 items"
(fmt/clj-format nil [:int " item" [:plural {:rewind true}]] 5) ;; => "5 items"
(fmt/clj-format nil :cardinal 42)                               ;; => "forty-two"

clj-format.core/parse-format

(fmt/parse-format s)

Parse a cl-format format string into the DSL. Returns a vector of elements: literal strings, bare keywords (simple directives), and vectors (directives with options or compound directives).

(fmt/parse-format "~A")             ;=> [:str]
(fmt/parse-format "Hello ~A!")      ;=> ["Hello " :str "!"]
(fmt/parse-format "~R file~:P")     ;=> [:cardinal " file" [:plural {:rewind true}]]
(fmt/parse-format "~{~A~^, ~}")    ;=> [[:each {:sep ", "} :str]]
(fmt/parse-format "~:[no~;yes~]")  ;=> [[:if "yes" "no"]]
(fmt/parse-format "~:(~A~)")       ;=> [[:str {:case :capitalize}]]

When parse-format rejects an input it throws ExceptionInfo with structured ex-data describing the parse failure. Errors raised by clojure.pprint/cl-format itself still come from that library.

clj-format.core/compile-format

(fmt/compile-format dsl-body)

Compile a DSL form into a cl-format format string. The inverse of parse-format. Accepts a body vector, a single directive vector, or a bare keyword.

(fmt/compile-format :str)                       ;=> "~A"
(fmt/compile-format [:str])                      ;=> "~A"
(fmt/compile-format [:str {:width 10}])          ;=> "~10A"
(fmt/compile-format ["Hello " :str "!"])         ;=> "Hello ~A!"
(fmt/compile-format [:cardinal " file" [:plural {:rewind true}]])
                                                ;=> "~R file~:P"
(fmt/compile-format [:each {:sep ", "} :str])    ;=> "~{~A~^, ~}"
(fmt/compile-format [:if "yes" "no"])            ;=> "~:[no~;yes~]"

Round-trip fidelity: (= s (compile-format (parse-format s))) holds for any valid format string.

When compile-format rejects an invalid DSL form it throws ExceptionInfo with structured ex-data describing the compile-phase error.

Development

lein test                              # run all tests
lein test clj-format.core-test        # API mechanics
lein test clj-format.parser-test       # parser tests
lein test clj-format.compiler-test     # compiler + round-trip tests
lein test clj-format.examples-test     # cl-format output equivalence
lein repl                              # start a REPL

Background and References

The FORMAT facility originated in MIT Lisp Machine Lisp and was standardized as part of Common Lisp. clj-format builds on the Clojure implementation in clojure.pprint/cl-format.

Specification

Clojure Implementation

Examples and Tutorials

License

Copyright 2026

This program and the accompanying materials are made available under the terms of the Eclipse Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0.

This Source Code may also be made available under the following Secondary Licenses when the conditions for such availability set forth in the Eclipse Public License, v. 2.0 are satisfied: GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version, with the GNU Classpath Exception which is available at https://www.gnu.org/software/classpath/license.html.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close