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 ./mill benchmark.runAll. 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, close on reads. write is 3.4M / 67k / 93k / 4.9k against GenCodec's 3.3M / 54k / 83k / 4.2k — ahead on every model. read sits close to GenCodec, trading places model by model.
  • Writes vs. the Scala 3 field: strong. mcodec beats circe, play-json, borer, uPickle and GenCodec on every model; only jsoniter-scala and zio-json are faster, and on the nested Company model it's within 3% of jsoniter-scala. On reads it sits with circe / uPickle / GenCodec and behind zio-json / borer / jsoniter-scala — the recursive-ADT read is the one model where it trails the field.
  • Bytecode is no longer the outlier — it's now one of the leaner libraries. Collapsing made's per-field match-type indirection into non-inline helpers brought emitted bytecode at N=100 down to 2,900 KB — less than borer, GenCodec, uPickle and zio-json, behind only play-json / circe / jsoniter-scala. (This benchmark measured 9,105 KB before the made 0.6.0 rework, and briefly regressed to 6,330 KB when a well-intentioned .toArray.map(...) → .toArrayOf compile-time tweak turned out to duplicate its loop body into every derivation call site — a reminder that bytecode has to be measured, not assumed, once inline is involved.)
  • Compile time has closed most of the gap. mcodec is still the slowest-scaling Scala 3 library in the group — ~6.8 s for 50 + 12 codecs (~9.6 s at N=100) vs. ~8.4–8.5 s for circe / borer / zio-json and ~4.9 s for GenCodec's Scala 2 macro — and it's now faster than uPickle (10.0 s) at N=100, where it used to be the clear outlier. 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,261,256 47,675 88,729 4,323
circe 1,226,952 29,101 48,307 2,429
gencodec 3,329,172 54,396 82,930 4,235
jsoniter 5,571,231 68,513 183,683 7,018
mcodec 3,442,128 66,771 93,405 4,939
play-json 490,309 18,660 n/a 1,806
upickle 2,794,586 54,149 87,394 4,125
zio-json 5,298,245 102,230 147,016 7,264

Throughput, ops/s (higher is better) — read.

library Primitives Company (nested) Geometry (ADT) Batch (collections)
borer 3,059,707 33,940 26,806 2,978
circe 1,217,657 20,237 22,778 1,876
gencodec 964,332 19,130 20,119 1,746
jsoniter 5,115,917 110,953 195,923 7,191
mcodec 1,443,350 20,240 19,429 1,901
play-json 717,201 13,138 n/a 1,290
upickle 1,395,668 25,157 22,604 2,017
zio-json 1,685,948 38,404 67,824 2,871

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. ./mill benchmark.runAll 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 0.9 1.6 3.0 4.3 5.9 8.5 3,459 KB
circe 0.9 1.7 3.1 4.4 5.9 8.4 2,507 KB
gencodec ¹ 0.8 1.2 2.1 2.8 3.6 4.9 3,122 KB
jsoniter 0.9 1.4 2.4 3.3 4.2 5.7 2,658 KB
mcodec 0.9 1.8 3.4 4.9 6.8 9.6 2,900 KB
play-json 0.9 1.7 2.5 3.3 4.3 5.6 2,432 KB
upickle 1.0 2.2 3.6 5.1 7.1 10.0 4,639 KB
zio-json 0.9 1.7 3.1 4.4 5.9 8.5 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.61 0.23 2.45 0.45 0.56 3.06
circe 0.07 0.68 0.22 2.97 0.30 0.50 3.65
jsoniter 0.07 0.59 0.21 1.24 0.34 0.45 1.84
mcodec 0.07 1.43 0.20 2.56 0.38 0.48 3.99
play-json 0.06 0.46 0.21 1.45 0.31 0.43 1.91
upickle 0.07 1.00 0.21 3.03 0.37 0.53 4.03
zio-json 0.07 0.70 0.21 1.97 0.48 0.73 2.67

compile scaling generated bytecode 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.6.0. 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-04
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 ./mill benchmark.runAll on a quiet dedicated machine for a citable dataset.

Reproducing

# everything (~1h): JMH matrix + compile sweep + report + charts
./mill benchmark.runAll

# quick sanity pass: 1 fork, fewer sizes (a few minutes)
./mill benchmark.runAll --mode quick

# just one slice
./mill benchmark.runJmh 'CompanyBench.*' -p lib=mcodec,circe
./mill benchmark.gencodec.runJmh 'CompanyBench.*'   # GenCodec (Scala 2.13)
python3 benchmark/compile/bench_compile.py --libs mcodec,circe --sizes 0,25,50