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. write is 2.68M / 62k / 94k / 5.0k against GenCodec's 2.77M / 54k / 83k / 3.9k — parity on Primitives, ahead everywhere else. read is 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 inline derivation 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.

write throughput relative to mcodec read throughput relative to mcodec

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

compile scaling compile phases

Caveats

  • Single machine, single JDK. Absolute numbers do not transfer between machines; the ratios between libraries are the portable part.
  • String substrate. mcodec's public API (Json.read / Json.write) is String-only, so the whole matrix uses String. This understates the byte-native libraries (jsoniter-scala, borer), which are built for Array[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 CodecMakerConfig beyond recursion, no circe Configuration, etc.). A tuned setup can move the numbers.
  • play-json covers only the three product models — its Scala 3 Json.format macro 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