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.
writeis 3.4M / 67k / 93k / 4.9k against GenCodec's 3.3M / 54k / 83k / 4.2k — ahead on every model.readsits 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 themade0.6.0 rework, and briefly regressed to 6,330 KB when a well-intentioned.toArray.map(...)→.toArrayOfcompile-time tweak turned out to duplicate its loop body into every derivation call site — a reminder that bytecode has to be measured, not assumed, onceinlineis 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.

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 |

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.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
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-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