Benchmarks
How mcodec compares to popular Scala 3 JSON libraries on the two axes that matter for a derivation-based codec: how long it takes the compiler to derive codecs, and how fast those codecs run.
Numbers below are regenerated by
benchmark/scripts/run_all.sh. They are single-machine measurements — treat them as indicative, not authoritative. The environment for the committed run is under Environment.
Takeaways
- vs. GenCodec (the design mcodec copies): ahead on writes, level on reads.
writeis 2.68M / 62k / 94k / 5.0k against GenCodec's 2.77M / 54k / 83k / 3.9k — parity on Primitives, ahead everywhere else.readis within noise of GenCodec on every model. - Writes vs. the Scala 3 field: strong. mcodec beats circe, play-json and borer on every model and is level with uPickle; only jsoniter-scala and zio-json are faster. On reads it sits with circe / uPickle / GenCodec and behind zio-json and borer — the recursive-ADT read is the one model where it trails the field.
- jsoniter-scala is its own league — 2–5× everyone else on both axes.
- Compile time is the weak spot. The
transparent inlinederivation is the steepest-scaling in the group — ~9 s for 50 + 12 codecs (~13 s at N=100) vs. ~5–8 s for the Scala 3 macro libraries and ~4 s for GenCodec's Scala 2 macro (≈2.3× mcodec's own ancestor). It also emits ~3.5× more bytecode than circe or jsoniter. The cost is concentrated in the typer and inlining phases.
Libraries under test
| library | version | derivation | notes |
|---|---|---|---|
| mcodec | this tree | MCodec.derived (Made mirrors + inline) |
JSON backend, String I/O |
| circe + circe-generic | 0.14.16 | deriveCodec (semi-auto, Shapeless-3) |
ecosystem default |
| jsoniter-scala | 2.40.1 | JsonCodecMaker.make (macro) |
byte-native; performance reference |
| uPickle | 4.4.3 | macroRW |
single ReadWriter, closest API analogue |
| zio-json | 1.0.0 | DeriveJsonCodec.gen (macro) |
|
| borer | 1.18.0 | deriveCodec / deriveAllCodecs (macro) |
byte-native; streaming design like mcodec |
| play-json | 2.10.8 | Json.format (macro) |
products only — no Scala 3 sealed-hierarchy macro |
| GenCodec (AVSystem commons) | 2.29.0 | GenCodec.materialize (macro) |
the design mcodec copies. Scala 2.13 only — runtime rows only |
Scenarios
Four models, each stressing a different part of a codec — see benchmark/src/models/Models.scala:
| model | shape | stresses |
|---|---|---|
| Primitives | flat 12-field record | number formatting/parsing, Option, field dispatch |
| Company (nested) | 5 types, 3 levels, ~40 employees, BigDecimal |
recursion, object framing |
| Geometry (ADT) | GeoJSON-flavoured sealed hierarchy, 7 cases, self-recursive | discriminator write + lookup |
| Batch (collections) | 500 events, Vector / Map / Set / List |
collection builders, map keys, large payload (~150 KB) |
Serialization / deserialization
Throughput of value → String (write) and String → value (read), measured with JMH, Mode.Throughput. The suite's annotations request @Fork(3), 5×1s warmup, 10×1s measurement; the committed run used -f 3 -wi 5 -i 10 on a laptop — enough to rank the libraries, not a substitute for a full run on a dedicated box.
Every library is measured on the same A ↔ String substrate. jsoniter-scala and borer are byte-native and pay a UTF-8 conversion here that they would not pay against Array[Byte] — so their String numbers understate their native path.
Each library reads back its own output — wire shapes differ between libraries (nested-discriminator vs. "type" field, etc.) and that is expected; the benchmark measures each library's own round trip, not cross-compatibility.
GenCodec runs from a separate Scala 2.13 build (benchmark/gencodec/) — same models, payloads and JMH settings, through JsonStringOutput / JsonStringInput. Same JVM, so throughput is directly comparable.
Throughput, ops/s (higher is better) — write.
| library | Primitives | Company (nested) | Geometry (ADT) | Batch (collections) |
|---|---|---|---|---|
| borer | 2,154,284 | 44,463 | 89,099 | 4,259 |
| circe | 1,176,097 | 27,607 | 47,111 | 2,068 |
| gencodec | 2,769,762 | 53,698 | 83,343 | 3,883 |
| jsoniter | 9,460,077 | 124,316 | 180,647 | 7,170 |
| mcodec | 2,677,377 | 62,407 | 93,546 | 4,956 |
| play-json | 583,420 | 20,996 | n/a | 1,991 |
| upickle | 3,075,142 | 61,777 | 91,892 | 3,839 |
| zio-json | 5,239,481 | 98,183 | 145,846 | 7,277 |
Throughput, ops/s (higher is better) — read.
| library | Primitives | Company (nested) | Geometry (ADT) | Batch (collections) |
|---|---|---|---|---|
| borer | 2,947,058 | 44,065 | 46,352 | 3,438 |
| circe | 871,773 | 21,114 | 29,887 | 1,917 |
| gencodec | 951,710 | 22,108 | 29,039 | 1,979 |
| jsoniter | 5,143,890 | 106,796 | 199,774 | 6,728 |
| mcodec | 1,193,576 | 22,771 | 27,544 | 2,159 |
| play-json | 703,554 | 12,913 | n/a | 1,324 |
| upickle | 1,358,158 | 29,536 | 30,525 | 2,239 |
| zio-json | 1,835,304 | 36,245 | 63,104 | 2,636 |
Charts show throughput relative to mcodec on each model (mcodec = 1.0, taller is faster); absolute ops/s are in the tables above.

Compilation time
For each library, a clean compile (scala-cli compile --server=false, no Bloop, no incremental compiler) of a generated project containing N derived codecs: N independent case classes of 8–14 fields plus N/4 sealed hierarchies of 6 cases each. run_all.sh full sweeps N ∈ {0, 1, 10, 25, 50, 100}; the committed run goes to 100, 3 runs each, mean reported. (Nesting depth is a runtime concern, covered by the Company model above; this sweep isolates how compile time scales with the number of derived codecs.)
The N=0 row is the fixed cost (JVM + scalac startup + dependency classpath); the slope from N=1 upward is the derivation cost. Generator and driver: benchmark/compile/.
GenCodec is included here as a cross-ecosystem reference — it is compiled by Scala 2.13, so it is not a like-for-like measurement against the Scala 3 compiler, but it does answer "is mcodec's Scala 3 inline derivation cheaper or dearer than the Scala 2 macro it replaces?". Scala 2.13 has no built-in -Yprofile-trace, so GenCodec has no phase-breakdown row.
Clean compile wall time, seconds (lower is better); last column is total emitted .class bytes. N = derived codecs.
| library | N=0 | N=1 | N=10 | N=25 | N=50 | N=100 | bytecode @ N=100 |
|---|---|---|---|---|---|---|---|
| borer | 1.0 | 1.8 | 3.3 | 4.8 | 6.4 | 8.9 | 3,459 KB |
| circe | 1.0 | 1.9 | 3.4 | 4.9 | 6.4 | 9.4 | 2,507 KB |
| gencodec ¹ | 0.9 | 1.3 | 2.4 | 3.2 | 4.0 | 5.4 | 3,122 KB |
| jsoniter | 1.0 | 1.6 | 2.6 | 3.6 | 4.6 | 6.2 | 2,658 KB |
| mcodec | 1.0 | 2.0 | 4.2 | 6.4 | 9.0 | 13.4 | 9,105 KB |
| play-json | 1.0 | 1.9 | 2.8 | 3.6 | 4.7 | 6.2 | 2,432 KB |
| upickle | 1.0 | 2.3 | 3.9 | 5.6 | 7.5 | 11.1 | 4,639 KB |
| zio-json | 1.0 | 1.8 | 3.4 | 4.9 | 6.5 | 9.3 | 5,306 KB |
¹ GenCodec is compiled by Scala 2.13.18, not the Scala 3 compiler — a cross-ecosystem data point, not a like-for-like measurement.
Compiler phase breakdown
Seconds per scalac phase for the N=50 project. Macro / mirror derivation and inline expansion land in typer and inlining — their sum is the derivation cost.
| library | parser | typer | posttyper | inlining | erasure | genBCode | typer+inlining |
|---|---|---|---|---|---|---|---|
| borer | 0.07 | 0.67 | 0.26 | 2.70 | 0.49 | 0.57 | 3.37 |
| circe | 0.07 | 0.76 | 0.26 | 3.15 | 0.34 | 0.53 | 3.91 |
| jsoniter | 0.08 | 0.71 | 0.30 | 1.38 | 0.35 | 0.48 | 2.08 |
| mcodec | 0.07 | 1.96 | 0.27 | 3.30 | 0.56 | 1.01 | 5.25 |
| play-json | 0.06 | 0.52 | 0.23 | 1.62 | 0.34 | 0.49 | 2.14 |
| upickle | 0.08 | 1.12 | 0.23 | 3.47 | 0.42 | 0.58 | 4.59 |
| zio-json | 0.07 | 0.79 | 0.24 | 2.12 | 0.53 | 0.77 | 2.91 |

Caveats
- Single machine, single JDK. Absolute numbers do not transfer between machines; the ratios between libraries are the portable part.
Stringsubstrate. mcodec's public API (Json.read/Json.write) isString-only, so the whole matrix usesString. This understates the byte-native libraries (jsoniter-scala, borer), which are built forArray[Byte]and pay a UTF-8 conversion here.- Scala 3.9.0 (Scala Next). mcodec pins 3.9.0 via Made 0.4.1. Every competitor is published for 3.3 LTS and consumed here via TASTy forward-compatibility; a native 3.9.0 build of each might differ slightly.
- Default configuration. Each library uses its out-of-the-box derivation with no hand-tuning (no jsoniter
CodecMakerConfigbeyond recursion, no circeConfiguration, etc.). A tuned setup can move the numbers. - play-json covers only the three product models — its Scala 3
Json.formatmacro does not derive sealed hierarchies. - GenCodec is Scala 2.13 — a different compiler and standard library. Same-JVM runtime throughput is a fair comparison; its compile-time row is a cross-ecosystem reference (Scala 2 macro vs. Scala 3
inline), flagged as such and not stacked in the phase chart. - JIT warm-up is handled by JMH forking + warmup iterations; the compile sweep deliberately does the opposite (cold JVM every run).
Environment
Committed run:
date: 2026-09-02
cpu: Apple M4 Pro
ram: 48 GB
os: macOS 26.6.1 (25G76)
jdk: OpenJDK 26.0.1 (Homebrew)
scala: 3.9.0
scala-cli: 1.15.0
jmh: 1.37 (-f 3 -wi 5 -i 10)
A laptop under normal desktop load — fine for ranking, not for absolute numbers. Re-run benchmark/scripts/run_all.sh full on a quiet dedicated machine for a citable dataset.
Reproducing
# everything (~1h): JMH matrix + compile sweep + report + charts
benchmark/scripts/run_all.sh full
# quick sanity pass: 1 fork, fewer sizes (a few minutes)
benchmark/scripts/run_all.sh quick
# just one slice
scala-cli --power run benchmark --exclude gencodec --jmh -- 'CompanyBench.*' -p lib=mcodec,circe
scala-cli --power run benchmark/gencodec --jmh -- 'CompanyBench.*' # GenCodec (Scala 2.13)
python3 benchmark/compile/bench_compile.py --libs mcodec,circe --sizes 0,25,50