The fastest and safest JSON parser and serializer for Scala

The fastest and safest JSON parser and serializer for Scala

Evaluate JSON libraries, searching for the most secure, fastest, ergonomic, and less error-prone option. Go beyond the most popular choices.

Introduction

Jsoniter-Scala is a zero-dependency, high-performance, actively maintained JSON parser and serializer library for the Scala programming language. The library offers many features. The Jsoniter-Scala design aims to provide fast and efficient parsing and serialization of JSON data. One of its primary goals is the automatic generation of safe JSON codecs supporting various built-in data types. It also focuses on flexibility and customizable encoding/decoding options. As a result, it's a perfect choice for handling either small JSON messages or large JSON data sets. The library also provides a range of performance optimization techniques to process large JSON data sets efficiently. Those reasons make the Jsoniter-Scala popular for building high-performance web services and other data-intensive applications.

Dependencies

The latest version of Jsoniter-Scala is available in the Maven Central repository.

// Use the %%% operator instead of %% for Scala.js and Scala Native 
"com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % "2.19.0",
"com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % "2.19.0" % Provided

Supported platforms

Released for 2.12.x, 2.13.x and 3.x. (JVM/JS/Native).

The latest version requires JDK 11+ (use the library version 2.13.5.x for JDK 8+). Support for GraalVM Native Image 22+ without any platform restrictions.

Whatever your target is, a straightforward choice.

Security

The Jsoniter-Scala design focuses on security, offering configurable limits for suboptimal data structures with safe defaults to be resilient for DoS attacks. In addition, it generates codecs that create instances of a fixed set of classes during parsing to avoid RCE attacks.

Unfortunately, this is not standard, and many competing libraries are vulnerable. It's the thing that everyone should be concerned about. I don't want to scare you, but you should watch the "JSON Denial of Service" video by Sam Halliday.

Performance

Please find the latest results of benchmarks (using various JVMs). Also, see how Jsoniter-Scala looks compared to popular competitors such as Jackson, Circe, uPickle, zio-json, and others.

Please see an example diagram for GeoJSON Reading, showing a real-world message sample, a JSON full of floating point numbers. The Jsoniter-Scala performs very well and is outstanding compared to others.

Please see an example diagram for GeoJSON Reading, showing a real-world message sample, a JSON full of floating point numbers.

When browsing the rich set of benchmarks, see an interesting gc.alloc.rate.norm, which shows that the Jsoniter-Scala allocates much less than others. It's an option to select in the Score drop-down list at the top right corner of the benchmark page.
(Please also find a bit of the theory about what those benchmarks are about).

See also how well it performs in browsers and competes with other libraries that support Scala.js.

Last but not least, see also charts on running benchmarks on all CPUs simultaneously.

You can see that JVM on a notebook used for benchmark execution can allocate up to 15Gb/s rate. However, you cannot scale by adding more threads after reaching this rate. For example, if some parser in one thread allocates at a 10Gb/s rate, it doesn't increase throughput after using more than two threads.

Under the hood - why is it so fast?

While using a codec per class is a popular pattern for most JSON parsers, for the Jsoniter-Scala, it is an anti-pattern (with some rare exceptions). The Jsoniter-Scala provides full auto-derivation, so one needs to call derivation macros only for top-level types. It is handy and more efficient in most cases. Still, the Jsoniter-Scala can generate code that duplicates when messages have common parts that will provide pressure on the JIT compiler and CPU front-end in runtime. On the other hand, full semi-auto (codec per class) brings too many mega-morphic virtual method calls which cannot be in-lined. The most optimal way is somewhere in the middle for the arbitrary case when looking from the JVM side.

Goals

  • Safety: safe defaults oriented for different types of attacks,

  • Correctness: RFC-8259; parsing numbers without losing precision; do not use placeholder characters,

  • Performance: ultra-fast parsing and serialization with minimum allocations and copying,

  • Productivity: a simple one-liner definition for nested data structure without the need to derive intermediate codecs,

  • Ergonomics: flexibility with safety-oriented defaults

See also the more detailed description here.

How to use

Derive a codec for the top-level type to parse or serialize. We create a codec as below. Do it in the companion object for later auto-import of the codec in all places you'll use it. In Scala 2 we use implicit val instead of given.

import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*

final case class Driver(name: String, car: Seq[Car])

object Driver:
  given codec: JsonValueCodec[Driver] = JsonCodecMaker.make

final case class Car(id: Long, brand: String, model: Option[String])

Now, we can parse and serialize it with ease.

// serialize object to JSON string
val driver: Driver     = ???
val serialized: String = writeToString(driver)

// deserialize JSON string to object
val json: String         = ???
val deserialized: Driver = readFromString[Driver](json)

Practical suggestions and configuration

Hint: a syntactic sugar

The way I use Jsoniter is payload.fromJson , and object.toJson .

The above example would look:

// serialize object to JSON string
val driver: Driver     = ???
val serialized: String = driver.toJson

// deserialize JSON string to object
val json: String         = ???
val deserialized: Driver = json.fromJson[Driver]

If you like those shorthand calls, enable them using the following code. The below example is for the Try from StdLib, but you might implement something similar for an IO monad of your choice. For example, using the below code, you might plug the JsoniterSyntaticSugar trait somewhere and enjoy fromJson and toJson syntactic sugar.

import com.github.plokhotnyuk.jsoniter_scala.core.*
import scala.util.Try

trait JsoniterSyntaticSugar:
  extension (payload: String)
    def fromJson[T](using JsonValueCodec[T]): Try[T] =
      Try(readFromString(payload))
  extension [T](obj: T)
    def toJson(using JsonValueCodec[T]): Try[String] =
      Try(writeToString(obj))

Changing defaults for empty data

For most cases, you might use defaults (focused on performance), so define a codec for the top-level object, and that's it. Defaults are excellent, and I use them apart from one exceptional case regarding ADT that I will mention later in this chapter.

However, suppose one wants to modify the default behaviour in specific cases. E.g. the default absence of null data, omitting default values or empty collections.
What does it mean?

Transient None

Let's use the Driver example case class, and serialize the following:

val cars: Seq[Car] =
  Seq(
    Car(1L, "Aston Martin", None),
    Car(2L, "Kenworth", Some("W900"))
  )
val driver = Driver("James", cars)
val json = driver.toJson

Below is the result of how the json looks. Note that the first car doesn't have the "model" property (as this was marked None, so a null in JSON and omitted by default).

{
  "name": "James",
  "car": [
    {
      "id": 1,
      "brand": "Aston Martin"
    },
    {
      "id": 2,
      "brand": "Kenworth",
      "model": "W900"
    }
  ]
}

We might use codec configuration if there is a good reason to preserve those. If you scroll up to the code snippet with the Driver case class, you see that the codec in the companion object is defined as JsonCodecMaker.make . We might define it as follows: JsonCodecMaker.make(CodecMakerConfig.withTransientNone(false)) .

Now, the first car from the above JSON will have the explicit null information:

{"id":1,"brand":"Aston Martin","model":null}

Transient Empty

Regarding the empty collections (Nil), if we serialize Driver("James", Nil) , the result is just only the {"name":"James"}.

Now, let's define our codec considering those two settings.

given codec: JsonValueCodec[Driver] = 
  JsonCodecMaker.make {
    CodecMakerConfig
        .withTransientNone(false)
        .withTransientEmpty(false)
  }

The withTransientEmpty(false) setting makes the Driver("James", Nil) turns into {"name":"James","car":[]}.

Also, if the earlier discussed None appears in the car's description, it will result in a null in JSON.

Transient Default

Say, a default car in our fleet is Aston Martin. We redefine our case class as follows.

final case class Car(
  id: Int, 
  brand: String = "Aston Martin", 
  model: Option[String]
)

If it's our explicit business constraint, the other system (receiving information) knows that absence means default. We don't need to send apparent details. The Jsoniter will skip it by default.

To change it, use .withTransientDefault(false).

Other configuration parameters

See also the other available settings in the Scaladoc documentation.

Renaming properties

There are cases we might want to rename the field. For example, say the remote API uses an unclear acronym or shortened property name. Another example is a reserved word like "type".

// the Scala compiler allows us to escape it with backticks
final case class Issue(number: Int, `type`: String)

// optionally use the "named" annotation offered by the Jsoniter-Scala
final case class Issue(number: Int, @named("type") issueType: String)

Alternatively, we can use CodecMakerConfig, which is helpful if you want to change all field names from camelCase to snake_case.

given codec: JsonValueCodec[FooBarDto] =   
  JsonCodecMaker.make {
    CodecMakerConfig.withFieldNameMapper(
      JsonCodecMaker.enforce_snake_case
    )
  }

Parsing and serializing ADT (Algebraic Data Types)

As mentioned earlier, I use defaults in most cases. The only exception is ADT. It's about the standard discriminator field. This default happened historically because the first Jsoniter-Scala usages were for the replacement of Jackson-Module-Scala.

Usually, you define a codec only for the top-level type. However, if you want to modify behaviour for a particular type down in the hierarchy, it's possible. For example, I do this for ADT-based enums (with sealed trait or sealed abstract class), and Scala 3 enums.

Example Scala 3 enum:

enum HandlingMode:
  case Auto, Manual

object HandlingMode:
  given codec: JsonValueCodec[HandlingMode] =
    JsonCodecMaker.makeWithoutDiscriminator

We must remember to add the codec without discriminator to prevent using the default codec. If we forget, we will see something unwanted in many cases.

Let's use the above enum and serialize the following object.

Issue(1, HandlingMode.Manual).toJson

We get: {"number":1,"type":{"type":"Manual"}}.
But when adding the "without discriminator" option, we get what we want:
{"number":1,"type":"Manual"} .

It's the only specific setting one need to remember. Apart from that, we can always use defaults. Probably it's a good topic for a custom Wartremover linter rule that we might add to the wartremover/wartremover-contrib project. Let's see. ;-)

Serializing a simple value (not a JSON object)

According to RFC-8259, a valid JSON is an object, array, number, string, or one of three literal names: false, null, and true.

If you think about how to add a codec to a simple value like a string or boolean, an answer is to use a value class (AnyVal) or Opaque Types. Jsoniter-Scala supports value classes and opaque type aliases as the top-level type and nested (using automatically generated codec).

Please see an example value class as a top-level type when JSON's expected output is a simple boolean value.

final case class Reward(value: Boolean) extends AnyVal

object Reward:
  given codec: JsonValueCodec[Reward] = JsonCodecMaker.make

Now, the Reward(true).toJson gives us simply true as a serialized JSON string. We can do it similarly with other simple values.

Not different is with the Scala 3 opaque type.

opaque type Year = Int

object Year:
  given codec: JsonValueCodec[Year] = JsonCodecMaker.make
  def apply(value: Int): Year = value

The result of Year(2023).toJson is a JSON of 2023.

Here, we define a codec for a top-level type and serialize it directly. We don't need to define the codec explicitly if it's nested in a data structure.

Usages of Jsoniter-Scala

To name a few, you'll find it adopted in Sttp and Tapir by Sofwaremill and smithy4s by Disney Streaming. There's also a great Dijon library built on top of Jsoniter (a dynamically typed Scala JSON library). It's a great addition to your tests if you work with JSON fixtures and want to modify test data for different scenarios quickly. Please expect a separate article about Dijon.

Worth noting is also that zio-json recommends Jsoniter-Scala as a more performant alternative (if one can sacrifice ZIO integration and consider the library-agnostic option).

Summary

Jsoniter-Scala is a lightweight, ergonomic, high-performance library for working with JSON data in Scala. It's security and correctness oriented by design. Moreover, the library is actively maintained and supports the latest JSON and Scala ecosystem standards. Finally, it offers a wide range of integration options—ideal for projects requiring efficient JSON data handling.