Liking cljdoc? Tell your friends :D

oda

Fast JSON parser/writer for Clojure. Zero dependencies, JDK 25+.

oda is fast, low-allocation, and correct: it outperforms Jackson-backed libraries (jsonista, cheshire) on every workload we bench, read and write, allocates 2-5x less per operation, and is validated against the full JSONTestSuite corpus with bit-exact double parsing and writing (see Correctness).

Enabling the (incubating) Vector API adds SIMD string scanning for an extra boost on string-heavy documents — see Optional SIMD.

Status: alpha. API may still move.

Installation

Clojars Project

Requires JDK 25+. The jar ships the compiled Java core; when working from a git checkout instead, compile it once with clj -T:build javac.

Usage

(require '[s-exp.oda :as oda])

;; parse: byte-array, String or InputStream
(oda/parse "{\"a\":1,\"b\":[1.5,true,null]}")
;; => {"a" 1, "b" [1.5 true nil]}

(oda/parse "{\"a\":1}" {:key-fn keyword})
;; => {:a 1}

(oda/parse "{\"first-name\":\"Ada\"}"
           {:key-fn #(keyword (clojure.string/replace % "-" "_"))})
;; => {:first_name "Ada"}

;; write
(oda/write-str {:a 1 :b [1.5 true nil]})
;; => "{\"a\":1,\"b\":[1.5,true,null]}"

(oda/write-bytes {:a 1})            ;; => byte[]
(oda/write {:a 1} output-stream)    ;; progressive, bounded memory

Options

parse:

optiondefault
:key-fnnilfn of String -> key applied to object keys; nil keeps strings. clojure.core/keyword is recognized and takes an optimized interning path. Must be pure: results are cached by fn identity
:max-depth1000maximum nesting depth (stack-overflow/DoS guard)

write-str / write-bytes / write:

optiondefault
:default-fnnilcalled on values of unsupported types, must return a writable value; without it unsupported types throw
:date-formatyyyy-MM-dd'T'HH:mm:ss'Z' (UTC)format for java.util.Date/java.time.Instant values: a pattern string or a DateTimeFormatter (zone-less formatters default to UTC)

Supported types (write)

Maps (keys: keyword, string, symbol, number), vectors, sets, seqs, java.util.Map/Iterable, strings, keywords, symbols, chars, UUIDs, java.util.Date/java.time.Instant (see :date-format), all JVM numbers (Ratio written as double), booleans, nil. NaN/Infinity throw.

Performance

JMH (forked, average time, gc-profiled), Apple M-series, JDK 25, keyword keys. All multipliers are oda's speedup relative to jsonista (Jackson); higher is better, below 1.0x jsonista is faster. SIMD columns have the Vector API enabled (see below).

payloadreadread (SIMD)writewrite (SIMD)
number-heavy2.2x2.3x1.2x1.3x
small objects, repeated keys2.0x1.8x1.2x1.2x
citm_catalog1.5x1.6x2.6x2.5x
long ASCII strings1.5x3.6x1.2x4.8x
twitter.json1.1x1.2x2.0x2.0x
string-heavy (raw UTF-8)1.1x1.3x1.1x1.1x
string-heavy (\uXXXX escapes)0.9x1.0x1.1x1.1x

Jackson's escaped-string writer is bimodal across JVM forks (~1.5ms or ~4ms per op on the string payloads); the ratios above use its fast mode.

oda also allocates 2-5x less than jsonista per operation on most payloads (e.g. citm read: 1.4MB vs 7.9MB per op). Writes allocate nothing beyond the returned array (numbers included, via a Ryū port). Reproduce with:

clj -M:jmh quick vector    # or: full, scalar, plus payload/benchmark names

Optional SIMD

With the (incubating) Vector API enabled, string scanning and encoding go 16 bytes at a time.

Enable with:

clj -J--add-modules -Jjdk.incubator.vector ...

Without the module oda silently uses its scalar (SWAR) paths. -Doda.vector=false forces scalar.

Correctness

  • full JSONTestSuite corpus (all y_/n_/i_ cases)
  • differential testing against jsonista on the corpus and on generative payloads (test.check)
  • doubles are correctly rounded on both sides: Eisel-Lemire parsing and Ryū writing, validated bit-exact against Double/parseDouble on torture values plus thousands of generated round-trips
  • write→parse round-trip properties on random Clojure structures
  • duplicate object keys: last wins

Design notes

  • single-pass parser over byte[], fused tokenizer/builder, no token objects
  • SWAR (8-byte) string scanning, optional 16-byte SIMD via Vector API
  • object keys canonicalized through fixed-size lossy caches (no locks, no thread-locals, bounded memory, virtual-thread friendly)
  • PersistentArrayMap built directly for small objects, transient PersistentHashMap above 8 keys
  • numbers: inline long accumulation; doubles via a fused Eisel-Lemire conversion (ported from FastDoubleParser, MIT)
  • writer: flat instanceof dispatch, pre-escaped key fragment caches, pair-table long rendering; streaming writes flush a 64KB buffer

License

Copyright © Max Penet. Distributed under the Mozilla Public License 2.0 (see LICENSE).

EiselLemire.java is ported from FastDoubleParser, Copyright © Werner Randelshofer, MIT License. RyuDouble.java is adapted from ryu, Copyright © Ulf Adams, Apache License 2.0.

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