Liking cljdoc? Tell your friends :D

clj-format

Clojars Project

A Clojure, ClojureScript, and Babashka-friendly DSL for 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 host 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"

On ClojureScript, the same public API is available from clj-format.core and delegates to cljs.pprint/cl-format.

On Babashka, the same parser/compiler/core API works, and the full JVM-hosted suite is exercised under Babashka in CI.

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)

Tabular status board with :justify

;; cl-format:
;; ~36<Task~;Owner~;State~>~%~{~36<~A~;~A~;~A~>~%~}

(clj-format nil
  [[:justify {:width 36} "Task" "Owner" "State"] :nl
   [:each
    [:justify {:width 36} :str :str :str] :nl]]
  ["Parser port" "Dan" "done"
   "CLJS parity" "Dan" "green"])
;; =>
;; Task           Owner           State
;; Parser port         Dan         done
;; CLJS parity         Dan        green

Tabular numeric report with tabs

;; cl-format:
;; ~A~16T~A~28T~A~46T~A~%~14,,,'-A~16T~10,,,'-A~28T~16,,,'-A~46T~5,,,'-A~%~:{~A~16T~6,2F~28T~V~~46T~:*~D~%~}

(clj-format nil
  ["Name" [:tab {:col 16}] "Value" [:tab {:col 28}] "Histogram" [:tab {:col 46}] "Count" :nl
   [:str {:width 14 :fill \-}] [:tab {:col 16}]
   [:str {:width 10 :fill \-}] [:tab {:col 28}]
   [:str {:width 16 :fill \-}] [:tab {:col 46}]
   [:str {:width 5 :fill \-}] :nl
   [:each {:from :sublists}
    :str [:tab {:col 16}]
    [:float {:width 6 :decimals 2}] [:tab {:col 28}]
    [:tilde {:count :V}] [:tab {:col 46}]
    :back :int :nl]]
  "" "" "" ""
  [["Alpha" 3.14 5]
   ["Beta" 12.0 2]
   ["Gamma" 98.5 9]
   ["Delta" 42.42 7]])
;; =>
;; Name            Value       Histogram         Count
;; --------------  ----------  ----------------  -----
;; Alpha             3.14      ~~~~~             5
;; Beta             12.00      ~~                2
;; Gamma            98.50      ~~~~~~~~~         9
;; Delta            42.42      ~~~~~~~           7

Wrapped notation with :logical-block

;; cl-format:
;; ~<rgb(~;~D, ~D, ~D~;)~:>

(clj-format nil
  [[:logical-block "rgb(" [:int ", " :int ", " :int] ")"]]
  [255 140 0])
;; => "rgb(255, 140, 0)"

Word-wrapped prose

;; cl-format:
;; ~%~%~{~<~%~0,20:;~a ~>~}

(clj-format nil
  "~%~%~{~<~%~0,20:;~a ~>~}"
  ["The" "power" "of" "FORMAT" "is"
   "that" "it" "can" "wrap" "words"
   "beautifully."])
;; =>
;;
;; The power of FORMAT
;; is that it can wrap
;; words beautifully.

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 host cl-format:

  • clojure.pprint/cl-format on the JVM
  • cljs.pprint/cl-format in ClojureScript

writer — output destination:

  • nil or false — return formatted string
  • true — print to the host default output, return nil
  • a writer object — write to it, return nil

Writer details are host-specific:

  • Clojure uses java.io.Writer
  • ClojureScript uses cljs.core/IWriter

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
./bin/test-cljs                        # compile once, run shared CLJS suite via Node
bb test/clj_format/bb_runner.clj       # full Babashka suite
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