Coming from circe, zio-json or jsoniter-scala
This guide moves a small catalog of Scala books from circe, zio-json or jsoniter-scala to ZIO Blocks. By the end, each type has one derived Schema instead of a library-specific codec, your naming, discriminator and date-format rules live on a JsonCodecDeriver or in Modifier annotations, and you know the five places where habits from your old library silently do the wrong thing. Each source library gets its own section with the same three parts: the code you wrote, the code you write now, and what changes. Read the concept map, then jump to the section for the library you are leaving.
- Concept Map
- Coming from circe
- Coming from zio-json
- Coming from jsoniter-scala
- Traps for Migrating Code
- What You Gain and What Is Different
- Migration Checklist
The Problem
All three libraries derive a JSON codec from a case class, but the codec is the end of the road: it only does JSON. The same Book needs a second derivation for YAML, Avro or a database row, and every naming or discriminator rule is configured again for each one. The configuration also lives in a different place in each library — a Configuration value in implicit scope for circe, annotations for zio-json, a CodecMakerConfig passed to a macro for jsoniter-scala — so moving between them means relearning where each rule goes.
This is the old code, the same type with a codec for each library (not compiled here):
// circe
import io.circe.Codec
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
derives Codec.AsObject
// zio-json
import zio.json._
object Book { implicit val codec: JsonCodec[Book] = DeriveJsonCodec.gen[Book] }
// jsoniter-scala
import com.github.plokhotnyuk.jsoniter_scala.core._
import com.github.plokhotnyuk.jsoniter_scala.macros._
object Book { implicit val codec: JsonValueCodec[Book] = JsonCodecMaker.make }
In ZIO Blocks, a type derives a Schema once. The JSON codec is derived from that schema, and so is every other format's codec, from the same description and the same annotations.
Prerequisites
Add the schema module, which includes the JSON codec:
// Scala 2.13 and Scala 3.3 to 3.8
libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56"
// Scala 3.9 and newer (also needs scalacOptions += "-experimental")
libraryDependencies += "dev.zio" %% "zio-blocks-next-schema" % "0.0.56"
For Scala.js, use %%%. You can remove the circe, zio-json or jsoniter-scala dependency once the last codec is gone; on the JVM the schema module has no third-party dependencies, and on Scala.js it adds only the scala-java-time libraries.
This guide assumes you already know one of the three source libraries. It maps their concepts onto ZIO Blocks rather than teaching Schema from scratch, so if Schema.derived, toJsonString and fromJson are new to you, work through Your First Schema first. Every ZIO Blocks snippet below starts from these imports:
import zio.blocks.schema._
import zio.blocks.schema.json._
zio.blocks.schema._ brings in Schema, Modifier, NameMapper and the toJsonString and fromJson extension methods; zio.blocks.schema.json._ brings in JsonCodec, JsonCodecDeriver and DiscriminatorKind.
The Book Catalog Model
The running example is a catalog of Scala books. A Book records its title, its first author, the year it came out, an ISBN when we know it, and a list of tags. A sealed Format says how a copy is sold:
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
val zionomicon = Book("Zionomicon", "John A. De Goes", 2024, None, List("zio", "effects"))
val programmingInScala =
Book("Programming in Scala", "Martin Odersky", 2008, Some("9780981531601"), List("scala"))
val fpInScala =
Book("Functional Programming in Scala", "Paul Chiusano", 2014, Some("9781617290657"), Nil)
The ISBN of Zionomicon is unknown here, so it is None, and the page counts and file sizes in later examples are sample data.
Concept Map
The table maps each task to its spelling in the three source libraries and in ZIO Blocks:
| Task | circe 0.14 | zio-json 1.x | jsoniter-scala 2 | ZIO Blocks |
|---|---|---|---|---|
| Derive a codec | deriveCodec[A], derives Codec.AsObject | DeriveJsonCodec.gen[A] | JsonCodecMaker.make[A] | Schema.derived[A]; the JSON codec comes from the schema |
| Encode to a string | a.asJson.noSpaces | a.toJson | writeToString(a) | a.toJsonString |
| Decode from a string | decode[A](s) | s.fromJson[A] | readFromString[A](s) | s.fromJson[A] |
| Decode error | Left(io.circe.Error) | Left(String) | throws JsonReaderException | Left(SchemaError) |
| JSON AST | io.circe.Json | zio.json.ast.Json | none | zio.blocks.schema.json.Json, from a.toJson |
| Naming strategy for fields | Configuration#withSnakeCaseMemberNames | @jsonMemberNames(SnakeCase) | CodecMakerConfig#withFieldNameMapper | JsonCodecDeriver#withFieldNameMapper, @Modifier.fieldNaming |
| Rename one field | @JsonKey (circe-generic-extras, Scala 2) | @jsonField("name") | @named("name") | @Modifier.rename("name") |
| Accept an old field name | none | @jsonAliases("old") | none | @Modifier.alias("old") |
| Type discriminator field | Configuration#withDiscriminator("type") | @jsonDiscriminator("type") | "type" by default | DiscriminatorKind.Field("type"), @Modifier.discriminator("type") |
| Case names | Configuration#withTransformConstructorNames | @jsonHint, @jsonHintNames | @named, CodecMakerConfig#withAdtLeafClassNameMapper | @Modifier.rename on the case, @Modifier.caseNaming, JsonCodecDeriver#withCaseNameMapper |
| Leave a field out | none | @jsonExclude | @transient | @Modifier.transient() |
| Use defaults for missing input | Configuration#withDefaults | on by default | on by default | on by default |
| Reject unknown fields | Configuration#withStrictDecoding | @jsonNoExtraFields | CodecMakerConfig#withSkipUnexpectedFields(false) | @Modifier.noExtraFields(), JsonCodecDeriver#withRejectExtraFields(true) |
| Custom codec for one type | a given Encoder and Decoder in scope | a given JsonEncoder and JsonDecoder in scope | a given JsonValueCodec in scope | JsonCodecDeriver.withInstance[A](codec) |
The last row is the biggest change. In all three libraries, the derived codec searches implicit scope for an encoder and decoder for each field type. ZIO Blocks derivation never does: it reads the field types from the schema and uses its own codecs, unless you register an override on the deriver. The traps section shows what happens when you keep the old habit.
Coming from circe
circe derives an Encoder and a Decoder (or both, as a Codec) per type, and configured derivation reads a Configuration from implicit scope. In ZIO Blocks, the type gets a Schema, and configuration is a value you pass to the deriver.
Derived Codecs
The first change replaces the codec with a schema and keeps the encode and decode calls almost as they were.
You Wrote This
This is the old code, circe 0.14 on Scala 3 (not compiled here):
import io.circe.Codec
import io.circe.parser.decode
import io.circe.syntax._
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
derives Codec.AsObject
val programmingInScala = Book("Programming in Scala", "Martin Odersky", 2008, Some("9780981531601"), List("scala"))
println(programmingInScala.asJson.noSpaces)
println(decode[Book](programmingInScala.asJson.noSpaces))
You Write This
The schema in the companion replaces derives Codec.AsObject, toJsonString replaces asJson.noSpaces, and fromJson replaces decode:
println(programmingInScala.toJsonString)
// {"title":"Programming in Scala","firstAuthor":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"]}
println(programmingInScala.toJsonString.fromJson[Book])
// Right(Book(Programming in Scala,Martin Odersky,2008,Some(9780981531601),List(scala)))
Configured Derivation
circe's configured derivation puts the rules in a Configuration. The ZIO Blocks equivalent is a JsonCodecDeriver with the same rules, from which we derive the codec for each top-level type we send.
You Wrote This
This is the old code, with snake_case member names, a "type" discriminator and default values (not compiled here):
import io.circe.derivation.{Configuration, ConfiguredCodec}
given Configuration = Configuration.default.withSnakeCaseMemberNames.withDiscriminator("type").withDefaults
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
derives ConfiguredCodec
sealed trait Format derives ConfiguredCodec
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
}
You Write This
JsonCodecDeriver#withFieldNameMapper sets the naming strategy and JsonCodecDeriver#withDiscriminatorKind with DiscriminatorKind.Field sets the discriminator. Schema#derive builds a codec through that deriver:
val apiDeriver: JsonCodecDeriver =
JsonCodecDeriver
.withFieldNameMapper(NameMapper.SnakeCase)
.withDiscriminatorKind(DiscriminatorKind.Field("type"))
val bookCodec: JsonCodec[Book] = Book.schema.derive(apiDeriver)
val formatCodec: JsonCodec[Format] = Format.schema.derive(apiDeriver)
We call JsonCodec#encodeToString and JsonCodec#decode on the derived codecs:
println(bookCodec.encodeToString(programmingInScala))
// {"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"]}
println(formatCodec.encodeToString(Format.Paperback(320)))
// {"type":"Paperback","pages":320}
println(formatCodec.decode("""{"type":"Ebook","size_mb":4.5}"""))
// Right(Ebook(4.5))
There is no equivalent of Configuration#withDefaults to turn on: a field with a default value is filled in when it is missing from the input, and left out of the output when it holds that value. If the rule belongs to one type rather than to the service you talk to, annotate the type with @Modifier.fieldNaming("snake_case") or @Modifier.discriminator("type") instead, and the plain toJsonString picks it up. How to Customize JSON Field Names, ADT Encoding and Optional Fields covers every option and when to pick an annotation over a deriver setting.
Custom Codecs for One Type
In circe, a custom Encoder[LocalDate] and Decoder[LocalDate] in implicit scope change every derived codec that contains a LocalDate. In ZIO Blocks, you write one JsonCodec[LocalDate] and register it on the deriver.
You Wrote This
This is the old code, writing dates as dd/MM/yyyy (not compiled here):
import io.circe.{Codec, Decoder, Encoder}
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import scala.util.Try
val partnerDateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
given Encoder[LocalDate] = Encoder.encodeString.contramap(partnerDateFormat.format)
given Decoder[LocalDate] = Decoder.decodeString.emap { s =>
Try(LocalDate.parse(s, partnerDateFormat)).toEither.left.map(_ => s"expected a dd/MM/yyyy date, got: $s")
}
case class Order(title: String, orderedOn: LocalDate) derives Codec.AsObject
You Write This
A JsonCodec[LocalDate] reads and writes the string through the streaming JsonReader and JsonWriter. JsonCodecDeriver#withInstance registers it, and Schema#deriving derives a codec through the resulting deriver:
import java.time.LocalDate
import java.time.format.{DateTimeFormatter, DateTimeParseException}
val partnerDateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
val partnerDateCodec: JsonCodec[LocalDate] = new JsonCodec[LocalDate] {
def decodeValue(in: JsonReader): LocalDate = {
val s = in.readString()
try LocalDate.parse(s, partnerDateFormat)
catch { case _: DateTimeParseException => error(s"expected a dd/MM/yyyy date, got: $s") }
}
def encodeValue(x: LocalDate, out: JsonWriter): Unit =
out.writeVal(partnerDateFormat.format(x))
}
case class Order(title: String, orderedOn: LocalDate)
object Order {
implicit val schema: Schema[Order] = Schema.derived
}
val partnerDeriver = JsonCodecDeriver.withInstance[LocalDate](partnerDateCodec)
val orderCodec: JsonCodec[Order] = Order.schema.deriving(partnerDeriver).derive
The derived codec uses the custom format for orderedOn, and would do so for a LocalDate at any depth:
println(orderCodec.encodeToString(Order("Zionomicon", LocalDate.of(2025, 3, 14))))
// {"title":"Zionomicon","orderedOn":"14/03/2025"}
println(orderCodec.decode("""{"title":"Zionomicon","orderedOn":"14/03/2025"}"""))
// Right(Order(Zionomicon,2025-03-14))
The order date is sample data. How to Override the Codec for One Type Everywhere covers the full codec, including the methods for the Json value type, and the narrower scopes of one field or one derivation.
Decode Errors
circe returns an io.circe.Error, either a ParsingFailure or a DecodingFailure with a cursor history. ZIO Blocks returns a SchemaError, whose SchemaError#message names the problem and the path to it:
"""{"title":"Zionomicon","firstAuthor":"John A. De Goes","year":"2024"}""".fromJson[Book] match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// illegal number at: .year
The path after at: names the failing field and starts at . for the top-level value; here it points at the year field of the book. circe's Decoder[Int] accepts the string "2024", so circe decodes this input; ZIO Blocks requires a JSON number. SchemaError extends Exception, so code that converts the result with Either#toTry or rethrows the error keeps working.
What Changes
- A schema replaces the codec.
derives Codec.AsObjectorderiveCodecbecomesimplicit val schema: Schema[A] = Schema.derivedin the companion. The same schema gives you every other format. - Configuration is a value, not an implicit. A
Configurationin implicit scope becomes aJsonCodecDeriverthat you pass toSchema#derive, or aModifierannotation on the type. - Codecs in implicit scope are ignored. Register a custom codec with
JsonCodecDeriver.withInstance; see the traps. Noneand empty lists are left out by default. circe writes"isbn":nulland"tags":[]; ZIO Blocks omits both. See the traps.- Numbers must be JSON numbers. circe decodes
"year":"2024"into anInt; ZIO Blocks rejects it withillegal number. - Missing collections decode as empty. circe fails with
Missing required fieldwhentagsis absent and has no default; ZIO Blocks decodes it asNil. - The default sealed trait shape is the same. Without a discriminator, both wrap the case in an object keyed by its name, such as
{"Paperback":{"pages":320}}. With a discriminator, circe writes the"type"field last and ZIO Blocks writes it first; both read it from any position.
Coming from zio-json
zio-json and ZIO Blocks look alike on purpose: both derive from case classes, both configure with annotations, and both have toJson and fromJson. Most annotations map one to one, but two method names mean something slightly different.
Derived Codecs
DeriveJsonCodec.gen becomes Schema.derived, and the call sites keep their names.
You Wrote This
This is the old code, zio-json 1.x (not compiled here):
import zio.json._
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
object Book {
implicit val codec: JsonCodec[Book] = DeriveJsonCodec.gen[Book]
}
val json: String = programmingInScala.toJson
val back: Either[String, Book] = json.fromJson[Book]
You Write This
The schema replaces the codec. toJson becomes toJsonString, and fromJson keeps its name:
val json: String = programmingInScala.toJsonString
val back: Either[SchemaError, Book] = json.fromJson[Book]
The string and the decoded value are the same as before:
println(json)
// {"title":"Programming in Scala","firstAuthor":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"]}
println(back)
// Right(Book(Programming in Scala,Martin Odersky,2008,Some(9780981531601),List(scala)))
In ZIO Blocks, toJson exists too, but it returns the JSON AST, a zio.blocks.schema.json.Json, rather than a String. Its toString renders the JSON with indentation:
val ast: Json = programmingInScala.toJson
Printing the AST shows the same fields:
println(ast)
// {
// "title": "Programming in Scala",
// "firstAuthor": "Martin Odersky",
// "year": 2008,
// "isbn": "9780981531601",
// "tags": [
// "scala"
// ]
// }
The error side of fromJson is a SchemaError instead of a String. Where the old code expected a String, map the error to its message with SchemaError#message:
val asString: Either[String, Book] = """{"title":"Zionomicon"}""".fromJson[Book].left.map(_.message)
// asString: Either[String, Book] = Left(
// "missing required field \"firstAuthor\" at: ."
// )
println(asString)
// Left(missing required field "firstAuthor" at: .)
Annotations
zio-json annotations become Modifier annotations. They are part of the schema, so toJsonString, fromJson and every codec derived from the schema follow them.
You Wrote This
This is the old code, zio-json 1.x, with a naming strategy, a renamed field, an accepted old name, an excluded field, a discriminator and snake_case case names (not compiled here):
import zio.json._
@jsonMemberNames(SnakeCase)
case class Book(
title: String,
firstAuthor: String,
year: Int,
@jsonField("isbn_13") @jsonAliases("isbn") isbn: Option[String],
tags: List[String],
@jsonExclude searchScore: Double = 0.0
)
@jsonDiscriminator("type")
@jsonHintNames(SnakeCase)
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
}
You Write This
Each zio-json annotation has a Modifier counterpart, and the naming strategies are written as strings such as "snake_case":
import zio.blocks.schema._
@Modifier.fieldNaming("snake_case")
case class Book(
title: String,
firstAuthor: String,
year: Int,
@Modifier.rename("isbn_13") @Modifier.alias("isbn") isbn: Option[String],
tags: List[String],
@Modifier.transient() searchScore: Double = 0.0
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
@Modifier.discriminator("type")
@Modifier.caseNaming("snake_case")
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
val programmingInScala =
Book("Programming in Scala", "Martin Odersky", 2008, Some("9780981531601"), List("scala"), 0.93)
The plain extension methods follow the annotations. The renamed field and its alias both decode, and the transient field takes its default:
println(programmingInScala.toJsonString)
// {"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn_13":"9780981531601","tags":["scala"]}
println((Format.Paperback(320): Format).toJsonString)
// {"type":"paperback","pages":320}
println("""{"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn":"9780981531601"}""".fromJson[Book])
// Right(Book(Programming in Scala,Martin Odersky,2008,Some(9780981531601),List(),0.0))
The full mapping, including the annotations not shown here:
| zio-json | ZIO Blocks |
|---|---|
@jsonField("name") | @Modifier.rename("name") |
@jsonAliases("a", "b") | @Modifier.alias("a") @Modifier.alias("b") |
@jsonMemberNames(SnakeCase) | @Modifier.fieldNaming("snake_case") |
@jsonDiscriminator("type") | @Modifier.discriminator("type") |
@jsonHint("name") | @Modifier.rename("name") on the case |
@jsonHintNames(SnakeCase) | @Modifier.caseNaming("snake_case") |
@jsonExclude | @Modifier.transient() |
@jsonNoExtraFields | @Modifier.noExtraFields() |
What Changes
toJsonreturns aJsonvalue. UsetoJsonStringfor theStringthat zio-json'stoJsonreturns.fromJsonreturnsEither[SchemaError, A]. UseSchemaError#messagewhere you need the oldString.- Encoders in implicit scope are ignored. In zio-json, an implicit
JsonEncoder[LocalDate]andJsonDecoder[LocalDate]change every derived codec; register aJsonCodec[LocalDate]withJsonCodecDeriver.withInstanceinstead. See the traps. @Modifier.renamereplaces the name. Like@jsonField, the Scala name is no longer accepted; add@Modifier.aliaswith the old name if clients still send it. See the traps.- Empty collections are left out. zio-json writes
"tags":[]; ZIO Blocks omits the field, and decodes a missing collection as empty where zio-json fails with a missing-field error fortags. See the traps.Noneis omitted by both. - Numbers must be JSON numbers. zio-json decodes
"year":"2024"into anInt; ZIO Blocks rejects it. - Transient fields need a default. A
@Modifier.transient()field must have a default value unless it is anOptionor a collection;Schema.derivedreports the missing default at compile time.
Coming from jsoniter-scala
jsoniter-scala generates a codec at compile time from a CodecMakerConfig. In ZIO Blocks, Schema.derived describes the type, and a JsonCodecDeriver plays the role of the config: its with* methods mirror most of the options.
Derived Codecs
JsonCodecMaker.make becomes Schema.derived, and writeToString and readFromString become methods on the codec or extension methods on the value.
You Wrote This
This is the old code, jsoniter-scala 2 (not compiled here):
import com.github.plokhotnyuk.jsoniter_scala.core._
import com.github.plokhotnyuk.jsoniter_scala.macros._
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
object Book {
implicit val codec: JsonValueCodec[Book] =
JsonCodecMaker.make(CodecMakerConfig.withFieldNameMapper(JsonCodecMaker.enforce_snake_case))
}
val json = writeToString(programmingInScala)
val back: Book = readFromString[Book](json) // throws JsonReaderException on bad input
You Write This
A JsonCodecDeriver takes the place of CodecMakerConfig, and Schema#derive takes the place of JsonCodecMaker.make. Keep the derived codec in the companion, as you kept the jsoniter-scala one:
import zio.blocks.schema._
import zio.blocks.schema.json._
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
val codec: JsonCodec[Book] = schema.derive(JsonCodecDeriver.withFieldNameMapper(NameMapper.SnakeCase))
}
val programmingInScala =
Book("Programming in Scala", "Martin Odersky", 2008, Some("9780981531601"), List("scala"))
JsonCodec#encodeToString and JsonCodec#decode replace writeToString and readFromString. Bad input comes back as a Left instead of an exception:
println(Book.codec.encodeToString(programmingInScala))
// {"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"]}
println(Book.codec.decode(Book.codec.encodeToString(programmingInScala)))
// Right(Book(Programming in Scala,Martin Odersky,2008,Some(9780981531601),List(scala)))
println(Book.codec.decode("""{"title":"Zionomicon","first_author":42}"""))
// Left(zio.blocks.schema.SchemaError: expected '"' at: .firstAuthor)
JsonCodec also reads and writes Array[Byte], java.nio.ByteBuffer and java.io.InputStream/OutputStream, the counterparts of readFromArray, readFromByteBuffer and readFromStream.
Mapping CodecMakerConfig
Most CodecMakerConfig options that shape the JSON have a JsonCodecDeriver counterpart. jsoniter-scala's requireCollectionFields and requireDefaultFields also make the encoder write those fields, and so do their counterparts, so each is a single setting:
CodecMakerConfig | JsonCodecDeriver |
|---|---|
withFieldNameMapper(JsonCodecMaker.enforce_snake_case) | withFieldNameMapper(NameMapper.SnakeCase) |
withAdtLeafClassNameMapper(f) | withCaseNameMapper(NameMapper.Custom(f)) |
withDiscriminatorFieldName(Some("type")) (default) | withDiscriminatorKind(DiscriminatorKind.Field("type")) |
withDiscriminatorFieldName(None) | withDiscriminatorKind(DiscriminatorKind.Key) (default) |
withTransientNone(b) | withTransientNone(b) |
withTransientEmpty(b) | withTransientEmptyCollection(b) |
withTransientDefault(b) | withTransientDefaultValue(b) |
withRequireCollectionFields(true) | withRequireCollectionFields(true) |
withRequireDefaultFields(true) | withRequireDefaultValueFields(true) |
withSkipUnexpectedFields(false) | withRejectExtraFields(true) |
withCheckFieldDuplication(true) (default) | always on |
Several options have no JsonCodecDeriver setting:
withIsStringifiedand@stringified, which read and write numbers and booleans as JSON strings.withMapAsArray; maps are always JSON objects.- The input limits:
withBigDecimalPrecision,withBigDecimalScaleLimit,withBigDecimalDigitsLimit,withBigIntDigitsLimit,withBitSetValueLimit,withMapMaxInsertNumberandwithSetMaxInsertNumber. withRequireDiscriminatorFirst; the ZIO Blocks decoder finds the discriminator field at any position.withInlineOneValueClasses,withSkipNestedOptionValues,withCirceLikeObjectEncoding,withUseScalaEnumValueId,withTransientNull,withEncodingOnlyandwithDecodingOnly.
If your API depends on one of the encoding options, register a custom codec for the affected type with JsonCodecDeriver.withInstance, as in the circe section. withAllowRecursiveTypes has no counterpart because none is needed: Schema.derived handles recursive types.
Performance
jsoniter-scala users usually pick it for speed. The repository's benchmarks project has a JMH benchmark, JsonListOfRecordsBenchmark, that reads and writes lists of records with jsoniter-scala, zio-json, ZIO Schema and ZIO Blocks, but the repository publishes no results. Run it on your own hardware and payloads before deciding.
What Changes
- The default sealed trait shape is different. jsoniter-scala writes a
"type"field by default; ZIO Blocks wraps the case in an object keyed by its name. SetDiscriminatorKind.Field("type")to keep your current JSON. - Bad input returns a
Left.JsonCodec#decodereturnsEither[SchemaError, A]instead of throwing. - The defaults for
None, empty collections and default values match. Both libraries leave them out of the output and fill them back in when they are missing from the input. - The discriminator can come anywhere. jsoniter-scala requires the
"type"field first by default; ZIO Blocks reads it from any position and writes it first. - Codecs in implicit scope are ignored. A hand-written
JsonValueCodecfor a field type is not picked up; register it withJsonCodecDeriver.withInstance.
Traps for Migrating Code
These five habits compile in ZIO Blocks but don't do what they did in your old library. None of them produces an error, so check for each one as you migrate.
An Implicit Codec in Scope Is Ignored
In circe, zio-json and jsoniter-scala, an encoder and decoder for LocalDate in implicit scope change every derived codec. In ZIO Blocks, the deriver picks the codec for each field from the schema and never searches implicit scope, so the implicit below has no effect:
import zio.blocks.schema._
import zio.blocks.schema.json._
import java.time.LocalDate
import java.time.format.DateTimeFormatter
val partnerDateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
implicit val partnerDateCodec: JsonCodec[LocalDate] = new JsonCodec[LocalDate] {
def decodeValue(in: JsonReader): LocalDate = LocalDate.parse(in.readString(), partnerDateFormat)
def encodeValue(x: LocalDate, out: JsonWriter): Unit = out.writeVal(partnerDateFormat.format(x))
}
case class Order(title: String, orderedOn: LocalDate)
object Order {
implicit val schema: Schema[Order] = Schema.derived
}
val order = Order("Zionomicon", LocalDate.of(2025, 3, 14))
The date still comes out in ISO-8601 form:
println(order.toJsonString)
// {"title":"Zionomicon","orderedOn":"2025-03-14"}
Register the codec with JsonCodecDeriver.withInstance and derive through that deriver instead:
println(Order.schema.deriving(JsonCodecDeriver.withInstance[LocalDate](partnerDateCodec)).derive.encodeToString(order))
// {"title":"Zionomicon","orderedOn":"14/03/2025"}
toJsonString Ignores a Codec You Derived
toJsonString, toJson and fromJson always use Schema#jsonCodec, the codec from the default JsonCodecDeriver. A codec built with Schema#derive or Schema#deriving applies its settings only when you call it yourself:
import zio.blocks.schema._
import zio.blocks.schema.json._
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
val snakeCaseCodec: JsonCodec[Book] = schema.derive(JsonCodecDeriver.withFieldNameMapper(NameMapper.SnakeCase))
}
val programmingInScala =
Book("Programming in Scala", "Martin Odersky", 2008, Some("9780981531601"), List("scala"))
The extension method writes firstAuthor, and only the codec writes first_author:
println(programmingInScala.toJsonString)
// {"title":"Programming in Scala","firstAuthor":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"]}
println(Book.snakeCaseCodec.encodeToString(programmingInScala))
// {"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"]}
Keep a derived codec in one place, such as the companion, and call it everywhere you read or write that JSON. If the rule should apply to every caller, put it on the type with a Modifier annotation instead, which toJsonString does follow.
@Modifier.rename Drops the Scala Name
@Modifier.rename replaces the field name for both encoding and decoding, so the Scala name is no longer accepted. Unknown fields are skipped by default, so for an Option field, input that still uses the Scala name decodes without an error and the value is lost:
import zio.blocks.schema._
case class Book(title: String, @Modifier.rename("isbn_13") isbn: Option[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
The ISBN sent under its Scala name becomes None:
println("""{"title":"Programming in Scala","isbn":"9780981531601"}""".fromJson[Book])
// Right(Book(Programming in Scala,None))
Add the old name with @Modifier.alias, which the decoder accepts but the encoder never writes, the way @jsonAliases works in zio-json:
import zio.blocks.schema._
case class Book(title: String, @Modifier.rename("isbn_13") @Modifier.alias("isbn") isbn: Option[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
Now both names decode:
println("""{"title":"Programming in Scala","isbn":"9780981531601"}""".fromJson[Book])
// Right(Book(Programming in Scala,Some(9780981531601)))
println("""{"title":"Programming in Scala","isbn_13":"9780981531601"}""".fromJson[Book])
// Right(Book(Programming in Scala,Some(9780981531601)))
An Implicit NameMapper Does Nothing
circe users put a Configuration in implicit scope, so an implicit NameMapper in the companion looks like the natural translation. Nothing reads it:
import zio.blocks.schema._
case class Book(title: String, firstAuthor: String)
object Book {
implicit val schema: Schema[Book] = Schema.derived
implicit val nameMapper: NameMapper = NameMapper.SnakeCase
}
The output keeps the Scala name firstAuthor:
println(Book("Programming in Scala", "Martin Odersky").toJsonString)
// {"title":"Programming in Scala","firstAuthor":"Martin Odersky"}
Use @Modifier.fieldNaming("snake_case") on the type, or JsonCodecDeriver#withFieldNameMapper with Schema#derive, as in the circe section.
None and Empty Collections Are Left Out
By default, the encoder leaves out a None field and an empty collection, and the decoder fills them back in as None and an empty collection when they are missing. circe writes them as null and [], so a client that expects every key to be present breaks:
import zio.blocks.schema._
import zio.blocks.schema.json._
case class Book(title: String, firstAuthor: String, year: Int, isbn: Option[String], tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val zionomicon = Book("Zionomicon", "John A. De Goes", 2024, None, Nil)
The default output has no isbn and no tags:
println(zionomicon.toJsonString)
// {"title":"Zionomicon","firstAuthor":"John A. De Goes","year":2024}
To keep circe's shape, turn both off on the deriver:
val circeShaped: JsonCodec[Book] =
Book.schema.derive(JsonCodecDeriver.withTransientNone(false).withTransientEmptyCollection(false))
None is then written as null and an empty list as []:
println(circeShaped.encodeToString(zionomicon))
// {"title":"Zionomicon","firstAuthor":"John A. De Goes","year":2024,"isbn":null,"tags":[]}
JsonCodecDeriver#withTransientDefaultValue(false) does the same for fields that hold their default value. Control Optional Fields, Empty Collections and Defaults covers the matching decoder settings, which make missing fields an error; JsonCodecDeriver#withRequireCollectionFields(true) and JsonCodecDeriver#withRequireDefaultValueFields(true) also make the encoder write empty collections and default values.
What You Gain and What Is Different
You gain:
- One derivation for every format. The
Schemathat gives you JSON also gives you YAML, Avro, MessagePack, TOON and the other formats in One Type, Many Formats, with the same annotations. - Configuration you can see. Rules are values on a
JsonCodecDeriveror annotations on the type, not instances found through implicit search, so the codec a call site uses is the one it names. - Errors with paths, never exceptions.
fromJsonandJsonCodec#decodereturnEither[SchemaError, A], and the message names the field that failed. - No third-party JSON dependencies. The schema module runs on Scala 2.13 and 3, on the JVM and Scala.js, and needs no JSON library.
What is different:
- Implicit codecs are not part of derivation. Overrides go through
JsonCodecDeriver.withInstance. - The extension methods use the default codec. A configured codec must be called directly.
Noneand empty collections are omitted by default, as in jsoniter-scala and unlike circe.- Some jsoniter-scala options have no counterpart, such as stringified numbers and maps as arrays.
Migration Checklist
- Add
zio-blocks-schema(orzio-blocks-next-schemaon Scala 3.9 and newer) andimport zio.blocks.schema._. - Replace every
deriveCodec,derives Codec.AsObject,DeriveJsonCodec.genorJsonCodecMaker.makewithimplicit val schema: Schema[A] = Schema.derivedin the companion. - Replace
asJson.noSpaces, zio-jsontoJsonandwriteToStringwithtoJsonString, anddecode,fromJsonandreadFromStringwithfromJsonorJsonCodec#decode. - Replace error handling on
io.circe.Error,StringorJsonReaderExceptionwithSchemaError. - Turn a circe
Configurationor a jsoniter-scalaCodecMakerConfiginto aJsonCodecDeriver, derive codecs withSchema#derive, and call those codecs directly instead oftoJsonString. - Replace zio-json annotations with their
Modifiercounterparts, and add@Modifier.aliasfor any renamed field whose Scala name clients still send. - Replace each implicit codec for a field type with a
JsonCodecregistered throughJsonCodecDeriver.withInstance, and delete the implicit. - Decide whether
Noneand empty collections may be omitted; if not, setwithTransientNone(false)andwithTransientEmptyCollection(false). - If you came from jsoniter-scala, set
DiscriminatorKind.Field("type")to keep your sealed trait JSON. - Round-trip a sample of real payloads through the new codecs and compare the JSON with what the old library writes.
Putting It Together
This is the catalog fully migrated: Schema.derived for every type, one JsonCodecDeriver that writes snake_case names, a "type" field, explicit nulls and empty lists, and dates as dd/MM/yyyy, and a renamed ISBN that still accepts its old name:
package jsonlibrarymigration
import zio.blocks.schema._
import zio.blocks.schema.json._
import java.time.LocalDate
import java.time.format.{DateTimeFormatter, DateTimeParseException}
/**
* Coming from circe, zio-json or jsoniter-scala — Complete Example
*
* A book catalog migrated to ZIO Blocks: Schema.derived for every type, one
* JsonCodecDeriver for snake_case names, a "type" discriminator, explicit nulls
* and empty lists, and dd/MM/yyyy dates, plus a renamed ISBN that still accepts
* its Scala name.
*
* Run with: cd schema-examples && sbt "runMain
* jsonlibrarymigration.BookCatalogMigratedExample"
*/
object BookCatalogMigratedExample extends App {
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
case class Book(
title: String,
firstAuthor: String,
year: Int,
@Modifier.rename("isbn_13")
@Modifier.alias("isbn")
isbn: Option[String],
tags: List[String],
addedOn: LocalDate,
formats: List[Format]
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
// Replaces an implicit Encoder/Decoder (circe), JsonCodec (zio-json) or JsonValueCodec (jsoniter-scala)
val partnerDateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
val partnerDateCodec: JsonCodec[LocalDate] = new JsonCodec[LocalDate] {
private def parse(s: String): LocalDate =
try LocalDate.parse(s, partnerDateFormat)
catch { case _: DateTimeParseException => error(s"expected a dd/MM/yyyy date, got: $s") }
def decodeValue(in: JsonReader): LocalDate =
parse(in.readString())
def encodeValue(x: LocalDate, out: JsonWriter): Unit =
out.writeVal(partnerDateFormat.format(x))
override def decodeValue(json: Json): LocalDate = json match {
case s: Json.String => parse(s.value)
case _ => error("expected a JSON string")
}
override def encodeValue(x: LocalDate): Json =
Json.String(partnerDateFormat.format(x))
}
// Replaces a circe Configuration or a jsoniter-scala CodecMakerConfig
val apiDeriver =
JsonCodecDeriver
.withFieldNameMapper(NameMapper.SnakeCase)
.withDiscriminatorKind(DiscriminatorKind.Field("type"))
.withTransientNone(false)
.withTransientEmptyCollection(false)
.withInstance[LocalDate](partnerDateCodec)
// Keep the codec in one place: toJsonString and fromJson would use the default codec instead
val bookCodec: JsonCodec[Book] = Book.schema.deriving(apiDeriver).derive
// The added-on dates, page counts and file sizes are sample data
val catalog = List(
Book("Zionomicon", "John A. De Goes", 2024, None, List("zio", "effects"), LocalDate.of(2025, 3, 14), Nil),
Book(
"Programming in Scala",
"Martin Odersky",
2008,
Some("9780981531601"),
List("scala"),
LocalDate.of(2025, 4, 2),
List(Format.Paperback(736))
),
Book(
"Functional Programming in Scala",
"Paul Chiusano",
2014,
Some("9781617290657"),
Nil,
LocalDate.of(2025, 5, 21),
List(Format.Paperback(320), Format.Ebook(4.5))
)
)
// Encode every book
catalog.foreach(book => println(bookCodec.encodeToString(book)))
// Decode a payload that still sends the ISBN under its Scala name
println(
bookCodec.decode(
"""{"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"],"added_on":"02/04/2025","formats":[{"type":"Paperback","pages":736}]}"""
)
)
// Read a decode error with its path
bookCodec.decode(
"""{"title":"Zionomicon","first_author":"John A. De Goes","year":2024,"tags":[],"added_on":"2025-03-14","formats":[]}"""
) match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// Check the round trip
println(catalog.forall(book => bookCodec.decode(bookCodec.encodeToString(book)) == Right(book)))
}
Running the Examples
The complete example is a runnable file in the schema-examples project. Clone the repository and change into the project:
git clone https://github.com/zio/zio-blocks.git
cd zio-blocks/schema-examples
schema-examples/src/main/scala/jsonlibrarymigration/BookCatalogMigratedExample.scala
package jsonlibrarymigration
import zio.blocks.schema._
import zio.blocks.schema.json._
import java.time.LocalDate
import java.time.format.{DateTimeFormatter, DateTimeParseException}
/**
* Coming from circe, zio-json or jsoniter-scala — Complete Example
*
* A book catalog migrated to ZIO Blocks: Schema.derived for every type, one
* JsonCodecDeriver for snake_case names, a "type" discriminator, explicit nulls
* and empty lists, and dd/MM/yyyy dates, plus a renamed ISBN that still accepts
* its Scala name.
*
* Run with: cd schema-examples && sbt "runMain
* jsonlibrarymigration.BookCatalogMigratedExample"
*/
object BookCatalogMigratedExample extends App {
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
case class Book(
title: String,
firstAuthor: String,
year: Int,
@Modifier.rename("isbn_13")
@Modifier.alias("isbn")
isbn: Option[String],
tags: List[String],
addedOn: LocalDate,
formats: List[Format]
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
// Replaces an implicit Encoder/Decoder (circe), JsonCodec (zio-json) or JsonValueCodec (jsoniter-scala)
val partnerDateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy")
val partnerDateCodec: JsonCodec[LocalDate] = new JsonCodec[LocalDate] {
private def parse(s: String): LocalDate =
try LocalDate.parse(s, partnerDateFormat)
catch { case _: DateTimeParseException => error(s"expected a dd/MM/yyyy date, got: $s") }
def decodeValue(in: JsonReader): LocalDate =
parse(in.readString())
def encodeValue(x: LocalDate, out: JsonWriter): Unit =
out.writeVal(partnerDateFormat.format(x))
override def decodeValue(json: Json): LocalDate = json match {
case s: Json.String => parse(s.value)
case _ => error("expected a JSON string")
}
override def encodeValue(x: LocalDate): Json =
Json.String(partnerDateFormat.format(x))
}
// Replaces a circe Configuration or a jsoniter-scala CodecMakerConfig
val apiDeriver =
JsonCodecDeriver
.withFieldNameMapper(NameMapper.SnakeCase)
.withDiscriminatorKind(DiscriminatorKind.Field("type"))
.withTransientNone(false)
.withTransientEmptyCollection(false)
.withInstance[LocalDate](partnerDateCodec)
// Keep the codec in one place: toJsonString and fromJson would use the default codec instead
val bookCodec: JsonCodec[Book] = Book.schema.deriving(apiDeriver).derive
// The added-on dates, page counts and file sizes are sample data
val catalog = List(
Book("Zionomicon", "John A. De Goes", 2024, None, List("zio", "effects"), LocalDate.of(2025, 3, 14), Nil),
Book(
"Programming in Scala",
"Martin Odersky",
2008,
Some("9780981531601"),
List("scala"),
LocalDate.of(2025, 4, 2),
List(Format.Paperback(736))
),
Book(
"Functional Programming in Scala",
"Paul Chiusano",
2014,
Some("9781617290657"),
Nil,
LocalDate.of(2025, 5, 21),
List(Format.Paperback(320), Format.Ebook(4.5))
)
)
// Encode every book
catalog.foreach(book => println(bookCodec.encodeToString(book)))
// Decode a payload that still sends the ISBN under its Scala name
println(
bookCodec.decode(
"""{"title":"Programming in Scala","first_author":"Martin Odersky","year":2008,"isbn":"9780981531601","tags":["scala"],"added_on":"02/04/2025","formats":[{"type":"Paperback","pages":736}]}"""
)
)
// Read a decode error with its path
bookCodec.decode(
"""{"title":"Zionomicon","first_author":"John A. De Goes","year":2024,"tags":[],"added_on":"2025-03-14","formats":[]}"""
) match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// Check the round trip
println(catalog.forall(book => bookCodec.decode(bookCodec.encodeToString(book)) == Right(book)))
}
Run it from the schema-examples directory:
sbt "runMain jsonlibrarymigration.BookCatalogMigratedExample"
To check that the example compiles without running it:
sbt compile
See Also
- Your First Schema: derive a schema, round-trip JSON and read decode errors.
- How to Customize JSON Field Names, ADT Encoding and Optional Fields: every naming, discriminator and optional-field option.
- How to Override the Codec for One Type Everywhere: custom codecs with
Deriver#withInstance. - How to Tune JSON Encoding and Decoding for Throughput: decode from bytes and streams, reuse codecs and size buffers.
- JSON: the JSON codec reference.
- Migrating from ZIO Schema: the migration guide for ZIO Schema 1.x users.