Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/clojure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
build-clj:
strategy:
matrix:
jdk: [8, 11, 17, 21]
jdk: [17, 21, 25]

name: Clojure (Java ${{ matrix.jdk }})

Expand Down
122 changes: 122 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,125 @@
## 2.0.0

jsonista now uses [Jackson 3](https://github.com/FasterXML/jackson-3) (`3.2.1`).

**Breaking changes:**

* **Requires Java 17+** (was Java 8+). Jackson 3 has a hard Java 17 baseline.
* **Jackson coordinates changed** from `com.fasterxml.jackson.*` to
`tools.jackson.*`. If you pin Jackson versions in your own project, update
them.
* **Trailing content after a JSON value is now an error.** Jackson 3 enables
`FAIL_ON_TRAILING_TOKENS` by default, so `(read-value "{} garbage")` throws
a `tools.jackson.core.exc.StreamReadException` instead of returning `{}`.
Measured directly, this check has no detectable performance cost.
* **Jackson exceptions are now unchecked.** `IOException` and
`JsonProcessingException` are replaced by `JacksonException` and its
subtypes, all of which extend `RuntimeException`. Existing
`catch IOException` handlers around jsonista calls will no longer fire.
* **`:factory` is JSON-only.** Jackson 3 requires format-specific mappers for
other formats; pass e.g. a `CBORMapper` via `:mapper` instead.
* **Objects with no bean accessors now serialize as `{}` instead of
throwing.** Jackson 2 enabled `FAIL_ON_EMPTY_BEANS` by default, so
serializing a value jsonista has no serializer for and that exposes no
getters (an unsupported class, a `deftype` with no fields, etc.) raised
`InvalidDefinitionException`. Jackson 3's own default has this feature
disabled, and jsonista now takes that default as-is rather than
overriding it, so the same value silently serializes to `"{}"`.
`:do-not-fail-on-empty-beans` is unaffected but is now a no-op unless
something else (a custom `:mapper` or `:modules`) re-enabled the feature.
* Enums now serialize using `toString` by default.
* **`java.net.URL` is no longer accepted by `read-value`/`read-values`.**
Jackson 3 removed the `URL` overloads from `ObjectMapper.readValue` and
`ObjectReader.readValues`, and jsonista now follows suit instead of
routing around it. Passing a `URL` throws `IllegalArgumentException: No
implementation of method: :-read-value of protocol: #'jsonista.core/ReadValue
found for class: java.net.URL` (or the equivalent for `ReadValues`) — not a
Jackson exception, since dispatch fails in the Clojure protocol layer
before ever reaching Jackson.

`ReadValue`/`ReadValues` are public protocols — jsonista's documented
extension mechanism — so URL support is trivially restorable. Simplest,
since `InputStream` is already supported and this needs no Jackson
imports:

```clojure
(with-open [in (.openStream url)] (j/read-value in))
(with-open [in (.openStream url)] (into [] (j/read-values in)))
```

Or restore `URL` as a first-class type in your own code:

```clojure
(extend-protocol j/ReadValue
java.net.URL
(-read-value [this mapper]
(with-open [in (.openStream ^java.net.URL this)]
(j/read-value in mapper))))
```

**Caution:** `read-values` is lazy. Wrapping it in `with-open` without
realizing the sequence *inside* the `with-open` closes the underlying
stream before it's consumed — the stream is closed once control leaves
the block, regardless of whether anything has read from it yet. This
fails silently in some contexts (an empty sequence, no error) and throws
`Stream closed` in others, depending on how much buffering happened
before the close. That's why the example above wraps `read-values` in
`into []` inside the `with-open`, forcing full realization before the
stream closes — don't return the lazy iterator/seq out of the block.

**Performance:**

* **Decoding is 15-23% faster with string keys and 28-48% faster with keyword
keys than 1.0.0** (measured across 100 B - 100 KB payloads with
cheshire/data.json as cross-run controls, see
`bench/2026-08-01-full-comparison.md`): repeated object keys hit a bounded
keyword cache instead of `Keyword.intern` (same clear-when-full bounding
strategy jackson-core uses internally for property-name interning in
`tools.jackson.core.util.InternCache`), JSON objects of 8 entries or
fewer build `PersistentArrayMap`s directly, and untyped decoding walks the
token stream in a single pass (`ClojureUntypedDeserializer`) instead of
double-dispatching through databind's `UntypedObjectDeserializer`.
* **JSON objects with 8 or fewer entries now decode to `PersistentArrayMap`**
(insertion-ordered, matching small Clojure map literals and cheshire)
instead of `PersistentHashMap`. Equality semantics are unchanged; only code
inspecting the concrete map class or relying on `PersistentHashMap` seq
order would notice.
* Encoding throughput is unchanged at payloads of 1 KB and up. Payloads under
~100 bytes encode slower than under Jackson 2; the regression is measurable
in the raw Jackson 3 engine with no jsonista code in the loop.
* **`java.util.Date` serialization no longer takes a lock.** `DateSerializer`
(behind the `:date-format` option) now formats through an immutable
`java.time.format.DateTimeFormatter` instead of a shared `SimpleDateFormat`
guarded by `synchronized`, removing the contention point when many threads
serialize dates through one mapper (3.3× throughput at 8 threads in a
contention microbenchmark). `:date-format` patterns are now interpreted by
`DateTimeFormatter.ofPattern`: common patterns — including the default
`yyyy-MM-dd'T'HH:mm:ss'Z'` — produce identical output, but a few pattern
letters (e.g. week-based `Y`) have subtly different semantics.

**Other changes:**

* `jackson-datatype-jsr310` is no longer a dependency — `java.time` support is
built into Jackson 3 databind.
* **`jsonista.tagged/encode-collection` now writes elements through the live
generator.** Jackson 3 removed `JsonGenerator.getCodec()`, which the old
implementation used to serialize each collection element to a `String` and
emit it with `writeRawValue`. Elements now go through `writePOJO` directly,
which also avoids the intermediate `String`.

Output is byte-identical under Jackson's default pretty-printer (verified
against the pre-port implementation). A **custom `PrettyPrinter`** that
breaks arrays across lines may see a difference, and it is a fix rather
than a regression: the old path serialized nested elements in a separate
pass starting from indentation depth zero and then embedded that text
verbatim, so nested content could come out mis-indented relative to its
surroundings. Elements are now written at their true nesting depth.
* **`:escape-non-ascii` is JSON-only.** `JsonWriteFeature/ESCAPE_NON_ASCII` is
JSON-format-specific in Jackson 3, unlike Jackson 2's format-agnostic
`JsonGenerator.Feature` equivalent. Combined with a non-JSON `:mapper`
(e.g. `CBORMapper`), `:escape-non-ascii` is now a documented no-op instead
of applying to the binary format as it did under Jackson 2.

## 1.0.0 (2026-03-06)

* Jsonista has been fairly stable for a couple of years now. Let's call this 1.0!
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Please file bug reports and feature requests to https://github.com/metosin/jsoni
* Create a topic branch from where you want to base your work (usually the master branch)
* Check the formatting rules from existing code (no trailing whitepace, mostly default indentation)
* Ensure any new code is well-tested, and if possible, any issue fixed is covered by one or more new tests
* Verify that all tests pass using ```lein midje```
* Verify that all tests pass using `lein all test`
* Push your code to your fork of the repository
* Make a Pull Request

Expand Down
104 changes: 54 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Blogged:

[![Clojars Project](http://clojars.org/metosin/jsonista/latest-version.svg)](http://clojars.org/metosin/jsonista)

Requires Java1.8+
Requires Java 17+

## Quickstart

Expand Down Expand Up @@ -98,8 +98,8 @@ Reading & writing directly into a file:
Adding support for [joda-time](http://www.joda.org/joda-time) Classes, used by [clj-time](https://github.com/clj-time/clj-time).

```clj
;; [com.fasterxml.jackson.datatype/jackson-datatype-joda "2.9.5"]
(import '[com.fasterxml.jackson.datatype.joda JodaModule])
;; [tools.jackson.datatype/jackson-datatype-joda "3.2.1"]
(import '[tools.jackson.datatype.joda JodaModule])
(import '[org.joda.time LocalDate])

(def mapper
Expand Down Expand Up @@ -145,13 +145,22 @@ See [docs/streaming.md](docs/streaming.md).
## Performance

* All standard encoders and decoders are written in Java
* Untyped decoding builds Clojure data structures directly, in a single pass
* Protocol dispatch with `read-value` & `write-value`
* Jackson `ObjectMapper` is used directly
* Small functions to support JVM Inlining

Measured using [lein-jmh](https://github.com/jgpc42/lein-jmh),
see [perf-tests](/test/jsonista/jmh.clj) for details.

Compared to jsonista `1.0.0` (Jackson 2), `2.0.0` (Jackson 3 plus decoder
optimizations) decodes 15-23% faster with string keys and 28-48% faster with
keyword keys at payloads of 100 bytes and up; encoding is unchanged at 1 KB
and up. Payloads under ~100 bytes encode slower than under Jackson 2 - the
regression is present in the raw Jackson 3 engine itself and is not jsonista
overhead. Method, controls and full data:
[bench/2026-08-01-full-comparison.md](/bench/2026-08-01-full-comparison.md).

### Throughput, relative

![encode](/docs/json-encode.png)
Expand All @@ -164,55 +173,50 @@ see [perf-tests](/test/jsonista/jmh.clj) for details.

![decode](/docs/json-decode-t.png)

The graphs are generated from the Jackson 3 benchmark run
(`bench/2026-08-01-full-jackson321.txt`).

### Throughput, data

```bash
➜ jsonista git:(master) ✗ lein jmh '{:file "benchmarks.edn", :type :quick, :format :table}'
{:% 100.0 :eta "00:00:00"}

:benchmark :name :mode :samples :score :score-error :params
----------------------------- ------- ----------- -------- ------------------ ------------ --------------
jsonista.jmh/encode-data-json :encode :throughput 5 2011809.137 ops/s 12600.809 {:size "10b"}
jsonista.jmh/encode-data-json :encode :throughput 5 382677.707 ops/s 2861.142 {:size "100b"}
jsonista.jmh/encode-data-json :encode :throughput 5 66403.631 ops/s 597.436 {:size "1k"}
jsonista.jmh/encode-data-json :encode :throughput 5 5480.185 ops/s 58.379 {:size "10k"}
jsonista.jmh/encode-data-json :encode :throughput 5 576.691 ops/s 15.682 {:size "100k"}
jsonista.jmh/encode-cheshire :encode :throughput 5 996875.314 ops/s 5688.227 {:size "10b"}
jsonista.jmh/encode-cheshire :encode :throughput 5 482130.613 ops/s 2685.181 {:size "100b"}
jsonista.jmh/encode-cheshire :encode :throughput 5 128936.005 ops/s 879.709 {:size "1k"}
jsonista.jmh/encode-cheshire :encode :throughput 5 12209.066 ops/s 94.285 {:size "10k"}
jsonista.jmh/encode-cheshire :encode :throughput 5 1258.157 ops/s 12.340 {:size "100k"}
jsonista.jmh/encode-jsonista :encode :throughput 5 6356105.348 ops/s 85360.100 {:size "10b"}
jsonista.jmh/encode-jsonista :encode :throughput 5 2010379.039 ops/s 67648.165 {:size "100b"}
jsonista.jmh/encode-jsonista :encode :throughput 5 409264.663 ops/s 3704.992 {:size "1k"}
jsonista.jmh/encode-jsonista :encode :throughput 5 34527.245 ops/s 251.065 {:size "10k"}
jsonista.jmh/encode-jsonista :encode :throughput 5 2934.595 ops/s 15.858 {:size "100k"}
jsonista.jmh/encode-jackson :encode :throughput 5 6275467.563 ops/s 123578.482 {:size "10b"}
jsonista.jmh/encode-jackson :encode :throughput 5 2092035.098 ops/s 11417.613 {:size "100b"}
jsonista.jmh/encode-jackson :encode :throughput 5 408380.251 ops/s 10912.350 {:size "1k"}
jsonista.jmh/encode-jackson :encode :throughput 5 31992.554 ops/s 230.781 {:size "10k"}
jsonista.jmh/encode-jackson :encode :throughput 5 2887.485 ops/s 12.491 {:size "100k"}
jsonista.jmh/decode-data-json :decode :throughput 5 2257552.949 ops/s 23890.443 {:size "10b"}
jsonista.jmh/decode-data-json :decode :throughput 5 498261.935 ops/s 2348.572 {:size "100b"}
jsonista.jmh/decode-data-json :decode :throughput 5 85191.855 ops/s 321.961 {:size "1k"}
jsonista.jmh/decode-data-json :decode :throughput 5 7763.264 ops/s 250.502 {:size "10k"}
jsonista.jmh/decode-data-json :decode :throughput 5 771.691 ops/s 6.559 {:size "100k"}
jsonista.jmh/decode-cheshire :decode :throughput 5 1099821.870 ops/s 14796.659 {:size "10b"}
jsonista.jmh/decode-cheshire :decode :throughput 5 544013.773 ops/s 4122.539 {:size "100b"}
jsonista.jmh/decode-cheshire :decode :throughput 5 109517.975 ops/s 911.623 {:size "1k"}
jsonista.jmh/decode-cheshire :decode :throughput 5 10017.553 ops/s 50.871 {:size "10k"}
jsonista.jmh/decode-cheshire :decode :throughput 5 1014.003 ops/s 18.609 {:size "100k"}
jsonista.jmh/decode-jsonista :decode :throughput 5 3476196.425 ops/s 21535.641 {:size "10b"}
jsonista.jmh/decode-jsonista :decode :throughput 5 792773.466 ops/s 8209.591 {:size "100b"}
jsonista.jmh/decode-jsonista :decode :throughput 5 160180.797 ops/s 554.940 {:size "1k"}
jsonista.jmh/decode-jsonista :decode :throughput 5 14151.302 ops/s 107.906 {:size "10k"}
jsonista.jmh/decode-jsonista :decode :throughput 5 1508.829 ops/s 5.855 {:size "100k"}
jsonista.jmh/decode-jackson :decode :throughput 5 5145394.434 ops/s 84237.662 {:size "10b"}
jsonista.jmh/decode-jackson :decode :throughput 5 1339393.911 ops/s 6660.176 {:size "100b"}
jsonista.jmh/decode-jackson :decode :throughput 5 274465.912 ops/s 1589.614 {:size "1k"}
jsonista.jmh/decode-jackson :decode :throughput 5 29607.044 ops/s 183.068 {:size "10k"}
jsonista.jmh/decode-jackson :decode :throughput 5 2539.491 ops/s 17.753 {:size "100k"}
```
Captured 2026-08-01 on Jackson 3.2.1, Apple Silicon MacBook Pro, OpenJDK
25.0.2 (Corretto): 1 fork, 3×3 s warmup + 5×3 s measurement per point (error
bars ≤3% for most cells). All numbers are ops/s, higher is better.

**encode** (string-keyed maps):

| | 10b | 100b | 1k | 10k | 100k |
|---------------|----:|-----:|---:|----:|-----:|
| data.json | 5,917,892 | 1,421,856 | 210,328 | 16,047 | 1,647 |
| cheshire | 2,519,476 | 1,541,245 | 471,888 | 44,068 | 4,253 |
| jsonista | 9,287,998 | 3,481,862 | 716,087 | 57,715 | 5,528 |
| Jackson (raw) | 9,376,698 | 3,523,568 | 655,584 | 55,877 | 5,534 |

**decode** (string keys):

| | 10b | 100b | 1k | 10k | 100k |
|---------------|----:|-----:|---:|----:|-----:|
| data.json | 8,481,497 | 2,108,504 | 346,346 | 27,968 | 2,602 |
| cheshire | 2,189,307 | 1,260,362 | 307,258 | 27,377 | 3,006 |
| jsonista | 9,031,389 | 2,411,301 | 420,738 | 37,290 | 3,759 |
| Jackson (raw) | 8,448,628 | 2,406,312 | 441,294 | 40,911 | 4,254 |

**encode, keyword keys** (`keyword-keys-object-mapper`; data.json writes
keyword keys natively, cheshire likewise):

| | 10b | 100b | 1k | 10k | 100k |
|-----------|----:|-----:|---:|----:|-----:|
| data.json | 7,047,371 | 1,394,450 | 216,929 | 16,633 | 1,619 |
| cheshire | 2,156,053 | 1,489,408 | 415,399 | 39,578 | 4,270 |
| jsonista | 8,852,407 | 3,388,647 | 666,115 | 56,608 | 5,357 |

**decode, keyword keys** (data.json with `:key-fn keyword`, cheshire with
`(parse-string s true)`):

| | 10b | 100b | 1k | 10k | 100k |
|-----------|----:|-----:|---:|----:|-----:|
| data.json | 7,449,560 | 1,757,660 | 325,437 | 27,528 | 2,688 |
| cheshire | 2,106,946 | 1,102,503 | 268,505 | 24,393 | 2,570 |
| jsonista | 8,814,925 | 2,520,516 | 443,022 | 39,829 | 3,964 |

## Origin story

Expand Down
Loading