JSON in Scala: Circe, Spray-JSON, Play JSON & Case Class Codecs
Last updated:
Scala JSON handling centers on three libraries: Circe for functional, type-safe parsing; Spray-JSON for Akka HTTP integration; and Play JSON for Play Framework. Circe is the most popular — import io.circe.parser._; decode[MyCase](jsonString) parses JSON and decodes to a case class, returning Either[DecodingFailure, MyCase] without throwing exceptions. import io.circe.generic.auto._ derives Encoder and Decoder instances for case classes automatically at compile time. Spray-JSON requires explicit RootJsonFormat instances: implicit val format = jsonFormat3(MyCaseClass) for a 3-field case class. Play JSON uses Json.parse returning JsValue, with (json \ "field").as[String] for path traversal. This guide covers Circe's auto-derivation, manual codecs, Spray-JSON format instances, Play JSON reads/writes, and using JSON with Akka HTTP and Play routes.
Circe: Parsing and Decoding JSON with Either
Circe's entry point is io.circe.parser. The parse function takes a JSON string and returns Either[ParsingFailure, Json] — a Json AST on success, or a ParsingFailure describing what went wrong on failure. No exceptions are thrown. The decode[T] function combines parsing and type decoding in one step, returning Either[DecodingFailure, T]. Both failure types extend io.circe.Error, which extends Throwable, so you can convert to a Try or throw explicitly if needed.
// build.sbt dependencies
// libraryDependencies ++= Seq(
// "io.circe" %% "circe-core" % "0.14.10",
// "io.circe" %% "circe-parser" % "0.14.10",
// "io.circe" %% "circe-generic" % "0.14.10"
// )
import io.circe._
import io.circe.parser._
import io.circe.generic.auto._
// ── 1. parse() — returns Either[ParsingFailure, Json] ─────────
val jsonString = """{"name":"Alice","age":30,"active":true}"""
val parseResult: Either[ParsingFailure, Json] = parse(jsonString)
parseResult match {
case Right(json) => println(s"Parsed: $json")
case Left(error) => println(s"Parse failed: ${error.message}")
}
// ── 2. Navigating the Json AST manually ───────────────────────
val json: Json = parseResult.getOrElse(Json.Null)
// HCursor for safe navigation — returns ACursor (accumulating)
val cursor = json.hcursor
val name: Either[DecodingFailure, String] = cursor.downField("name").as[String]
val age: Either[DecodingFailure, Int] = cursor.downField("age").as[Int]
// Chain multiple field reads with for-comprehension
val result: Either[DecodingFailure, (String, Int)] = for {
n <- cursor.downField("name").as[String]
a <- cursor.downField("age").as[Int]
} yield (n, a)
// Right(("Alice", 30))
// ── 3. decode[T]() — parse + decode in one step ───────────────
case class Person(name: String, age: Int, active: Boolean)
// Encoder/Decoder auto-derived by circe-generic.auto._
val decoded: Either[DecodingFailure, Person] = decode[Person](jsonString)
decoded match {
case Right(person) => println(s"Hello, ${person.name}!")
case Left(err) => println(s"Decode error at ${err.history}: ${err.message}")
}
// ── 4. Handling the Either result idioms ──────────────────────
// fold — transform both branches
val message: String = decoded.fold(
err => s"Error: ${err.message}",
p => s"Name: ${p.name}, Age: ${p.age}"
)
// getOrElse — fallback value
val person: Person = decoded.getOrElse(Person("Unknown", 0, false))
// toOption — discard error details
val personOpt: Option[Person] = decoded.toOption
// toTry — convert to exception-throwing Try
val personTry = decoded.toTry // Failure(DecodingFailure(...)) or Success(Person(...))
// ── 5. Decoding nested JSON ───────────────────────────────────
val nestedJson = """
{
"user": { "name": "Bob", "age": 25, "active": false },
"score": 98
}
"""
case class Response(user: Person, score: Int)
val resp: Either[DecodingFailure, Response] = decode[Response](nestedJson)
// Right(Response(Person("Bob", 25, false), 98))The HCursor returned by json.hcursor tracks the traversal history — if decoding fails, DecodingFailure.history contains the path of cursor operations that led to the failure, making error messages actionable. For example, a missing field produces DecodingFailure("Missing required field", List(DownField("name"))). The ACursor returned by downField is lazy — it only fails when you call .as[T], allowing you to chain navigation without short-circuiting on intermediate steps.
Auto-Derived Codecs for Case Classes
The io.circe.generic.auto._ import triggers Scala macro-based derivation of Encoder[T] and Decoder[T] for any case class or sealed trait hierarchy at compile time. There is no reflection, no code generation step, and no runtime overhead beyond what the generated code itself does. Field names map 1:1 to JSON keys by default. Nested case classes, Option[T] fields, List[T], Map[String, T], and sealed traits with case class subtypes are all supported automatically.
import io.circe._
import io.circe.parser._
import io.circe.syntax._ // adds .asJson extension method
import io.circe.generic.auto._ // derives Encoder/Decoder at compile time
// ── Simple case class ─────────────────────────────────────────
case class Address(street: String, city: String, zip: String)
case class User(
id: Int,
name: String,
email: String,
address: Address, // nested case class — auto-derived
tags: List[String], // collection — auto-derived
phone: Option[String] // optional field — auto-derived
)
// Encoding: case class → JSON string
val user = User(
id = 1,
name = "Alice",
email = "alice@example.com",
address = Address("123 Main St", "Springfield", "12345"),
tags = List("admin", "user"),
phone = Some("+1-555-0100")
)
val jsonOutput: String = user.asJson.spaces2
// {
// "id": 1,
// "name": "Alice",
// "email": "alice@example.com",
// "address": { "street": "123 Main St", "city": "Springfield", "zip": "12345" },
// "tags": ["admin", "user"],
// "phone": "+1-555-0100"
// }
// None is encoded as null:
val noPhone = user.copy(phone = None)
noPhone.asJson.spaces2
// "phone": null
// ── Sealed trait hierarchy — discriminated union ──────────────
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Triangle(base: Double, height: Double) extends Shape
// Circe encodes sealed traits with a "type" discriminator:
// { "Circle": { "radius": 5.0 } }
val shape: Shape = Circle(5.0)
val shapeJson: Json = shape.asJson
// {"Circle":{"radius":5.0}}
val decoded: Either[DecodingFailure, Shape] = decode[Shape]("""{"Circle":{"radius":5.0}}""")
// Right(Circle(5.0))
// ── @JsonCodec annotation (circe-generic) — alternative to auto._ ──
// import io.circe.generic.JsonCodec
// @JsonCodec
// case class Product(id: Int, name: String, price: Double)
// Generates companion object with Encoder/Decoder — same as auto._ but explicit
// ── Checking the implicit exists at compile time ───────────────
// implicitly[Encoder[User]] // compile error if derivation fails
// implicitly[Decoder[User]] // useful for debugging missing instancesSealed trait derivation uses a "wrapper object" encoding by default: Circle(5.0) becomes {"Circle": {"radius": 5.0}}. To switch to a flat discriminator field ({"type": "Circle", "radius": 5.0}), use circe-generic-extras with Configuration.default.withDiscriminator("type"). The auto._ wildcard import works for most cases, but if you have many case classes and compile times become slow, switch to semi-automatic derivation with import io.circe.generic.semiauto._ and explicitly call deriveEncoder[T]/deriveDecoder[T] in each companion object — this avoids re-deriving the same codec in every file that uses the type.
Manual Circe Encoders and Decoders
When auto-derivation doesn't fit — because field names differ from JSON keys, because you need custom logic, or because the JSON shape doesn't match the case class structure — write manual Encoder and Decoder instances. Use Encoder.instance[T] and Decoder.instance[T] for full control, or use deriveEncoder/deriveDecoder with circe-generic-extras configuration for field name transformations like camelCase → snake_case.
import io.circe._
import io.circe.parser._
import io.circe.syntax._
import io.circe.generic.semiauto._
// ── Manual Encoder — full control over JSON shape ─────────────
case class Money(amount: BigDecimal, currency: String)
implicit val moneyEncoder: Encoder[Money] = Encoder.instance { m =>
Json.obj(
"amount" -> Json.fromBigDecimal(m.amount),
"currency" -> Json.fromString(m.currency),
// Add computed field not in the case class:
"formatted" -> Json.fromString(s"${m.currency} ${m.amount}")
)
}
val money = Money(BigDecimal("19.99"), "USD")
money.asJson
// {"amount":19.99,"currency":"USD","formatted":"USD 19.99"}
// ── Manual Decoder — parse non-standard JSON shapes ──────────
implicit val moneyDecoder: Decoder[Money] = Decoder.instance { cursor =>
for {
amount <- cursor.downField("amount").as[BigDecimal]
currency <- cursor.downField("currency").as[String]
} yield Money(amount, currency)
}
decode[Money]("""{"amount":19.99,"currency":"USD","formatted":"USD 19.99"}""")
// Right(Money(19.99, USD))
// ── Semi-automatic derivation with field name mapping ─────────
import io.circe.generic.extras._
import io.circe.generic.extras.semiauto._
implicit val config: Configuration =
Configuration.default.withSnakeCaseMemberNames // camelCase → snake_case
// case class with camelCase fields, JSON uses snake_case
@ConfiguredJsonCodec
case class ApiUser(
userId: Int,
firstName: String,
lastName: String,
createdAt: String
)
// Decodes: {"user_id":1,"first_name":"Alice","last_name":"Smith","created_at":"2026-05-28"}
// ── Decoder for sum types (manual discriminator) ──────────────
sealed trait Event
case class ClickEvent(x: Int, y: Int) extends Event
case class KeyEvent(key: String) extends Event
implicit val eventDecoder: Decoder[Event] = Decoder.instance { cursor =>
cursor.downField("type").as[String].flatMap {
case "click" => cursor.as[ClickEvent] // re-uses auto-derived decoder
case "key" => cursor.as[KeyEvent]
case other => Left(DecodingFailure(s"Unknown event type: $other", cursor.history))
}
}
// ── Accumulating errors — collect ALL decode failures ─────────
import io.circe.generic.auto._
import cats.data.ValidatedNel
val badJson = """[{"name":"","age":-1},{"name":"Bob","age":25}]"""
// decodeAccumulating collects all errors instead of stopping at first
val validated: Either[NonEmptyList[DecodingFailure], List[User]] =
decode[List[User]](badJson).left.map(e => cats.data.NonEmptyList.one(e))
// Use parser.decodeAccumulating for true multi-error accumulationCustom encoders compose naturally: if you define an Encoder[Money], any case class containing a Money field will automatically use it during derivation. The same applies to decoders. This composability is the main advantage of Circe's typeclass approach over Spray-JSON's format-based approach — you define codecs once per type and they propagate transitively through all types that contain them.
Spray-JSON: RootJsonFormat for Akka HTTP
Spray-JSON is the default JSON library for Akka HTTP. It uses RootJsonFormat[T] instances, which you define explicitly using jsonFormatN helper methods where N is the number of fields in your case class. Fields map to JSON keys by their Scala name. Parsing throws spray.json.DeserializationException on failure — wrap in Try for safe handling. Akka HTTP's entity(as[T]) directive uses spray-json's implicit format for automatic request body deserialization.
// build.sbt
// libraryDependencies ++= Seq(
// "com.typesafe.akka" %% "akka-http-spray-json" % "10.5.x",
// "io.spray" %% "spray-json" % "1.3.6"
// )
import spray.json._
import DefaultJsonProtocol._ // provides implicit formats for primitives
// ── Define a 3-field case class ───────────────────────────────
case class Product(id: Int, name: String, price: Double)
// explicit format: jsonFormat3 for 3 fields (1-22 supported)
object ProductJsonProtocol extends DefaultJsonProtocol {
implicit val productFormat: RootJsonFormat[Product] = jsonFormat3(Product)
}
import ProductJsonProtocol._
// ── Serialize: case class → JSON string ──────────────────────
val product = Product(42, "Widget", 9.99)
val json: JsValue = product.toJson
println(json.prettyPrint)
// {
// "id": 42,
// "name": "Widget",
// "price": 9.99
// }
// ── Deserialize: JSON string → case class ─────────────────────
val jsonStr = """{"id":42,"name":"Widget","price":9.99}"""
val parsed: JsValue = jsonStr.parseJson // throws on malformed JSON
val back: Product = parsed.convertTo[Product] // throws DeserializationException
// Safe deserialization with Try:
import scala.util.Try
val safeResult: Try[Product] = Try(jsonStr.parseJson.convertTo[Product])
// ── Nested case classes ───────────────────────────────────────
case class Category(id: Int, label: String)
case class Catalog(category: Category, products: List[Product])
object CatalogProtocol extends DefaultJsonProtocol {
implicit val categoryFormat: RootJsonFormat[Category] = jsonFormat2(Category)
// Must define category format BEFORE catalog format (compiler needs it)
implicit val catalogFormat: RootJsonFormat[Catalog] = jsonFormat2(Catalog)
}
// ── Akka HTTP route with spray-json ───────────────────────────
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
val route =
path("products") {
post {
entity(as[Product]) { product => // auto-deserializes request body
val saved = product.copy(id = 100) // simulate save
complete(saved) // auto-serializes response
}
} ~
get {
complete(List(Product(1, "Foo", 4.99), Product(2, "Bar", 7.49)))
}
}
// ── Custom format for non-standard shapes ──────────────────────
case class Color(r: Int, g: Int, b: Int)
implicit object ColorFormat extends RootJsonFormat[Color] {
def write(c: Color): JsValue = JsString(f"#${c.r}%02x${c.g}%02x${c.b}%02x")
def read(json: JsValue): Color = json match {
case JsString(s) if s.startsWith("#") && s.length == 7 =>
Color(
Integer.parseInt(s.substring(1, 3), 16),
Integer.parseInt(s.substring(3, 5), 16),
Integer.parseInt(s.substring(5, 7), 16)
)
case _ => deserializationError(s"Expected hex color string like #rrggbb")
}
}
Color(255, 128, 0).toJson // JsString("#ff8000")Spray-JSON requires you to define format instances in dependency order — inner types first, outer types second. If Catalog contains a Category, the implicit val categoryFormat must appear before implicit val catalogFormat in the same scope. This explicit ordering is a common source of compilation errors for newcomers. Unlike Circe, spray-json does not support sealed trait hierarchies out of the box — you need a custom RootJsonFormat with manual type dispatch.
Play JSON: Reads, Writes, and Format
Play JSON uses three type classes: Reads[T] (deserialization), Writes[T] (serialization), and Format[T] = Reads[T] with Writes[T] (both). The Json.reads[T], Json.writes[T], and Json.format[T] macros derive these automatically for case classes — similar to Circe's semi-automatic derivation. Json.parse parses a JSON string to JsValue and json.validate[T] decodes to JsResult[T] (either JsSuccess or JsError).
// build.sbt
// libraryDependencies += "org.playframework" %% "play-json" % "3.0.4"
import play.api.libs.json._
// ── Define Format with macro derivation ──────────────────────
case class Order(id: String, total: Double, items: List[String])
// Json.format derives both Reads and Writes at compile time
implicit val orderFormat: Format[Order] = Json.format[Order]
// ── Serialize: case class → JsValue → JSON string ────────────
val order = Order("ord-001", 49.99, List("widget", "gadget"))
val jsValue: JsValue = Json.toJson(order)
val jsonString: String = Json.stringify(jsValue) // compact
val pretty: String = Json.prettyPrint(jsValue) // indented
// ── Deserialize: JSON string → JsValue → case class ──────────
val jsonStr = """{"id":"ord-001","total":49.99,"items":["widget","gadget"]}"""
val parsed: JsValue = Json.parse(jsonStr) // throws on malformed JSON
val result: JsResult[Order] = parsed.validate[Order]
result match {
case JsSuccess(order, _) => println(s"Order: ${order.id}, total: ${order.total}")
case JsError(errors) =>
val msg = JsError.toJson(errors) // convert errors to a JsObject
println(s"Validation failed: ${Json.stringify(msg)}")
}
// ── Path traversal with \ operator ────────────────────────────
val json = Json.parse("""{"user":{"name":"Alice","roles":["admin","editor"]}}""")
val name: JsValue = (json "user" "name").get // JsString("Alice")
val roles: Option[String] = (json "user" "name").asOpt[String] // Some("Alice")
val role0: JsValue = (json "user" "roles" 0).get // JsString("admin")
// \ for recursive lookup (all matches)
val allNames: Seq[JsValue] = (json \ "name") // Seq(JsString("Alice"))
// ── Custom Reads / Writes ─────────────────────────────────────
case class Money(amount: BigDecimal, currency: String)
implicit val moneyWrites: Writes[Money] = Writes { m =>
Json.obj(
"amount" -> m.amount,
"currency" -> m.currency
)
}
implicit val moneyReads: Reads[Money] = (
(__ "amount").read[BigDecimal] and
(__ "currency").read[String]
)(Money.apply _)
// ── Using in a Play controller ────────────────────────────────
// class OrderController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {
// def create: Action[JsValue] = Action(parse.json) { request =>
// request.body.validate[Order] match {
// case JsSuccess(order, _) =>
// val saved = orderService.save(order)
// Created(Json.toJson(saved))
// case JsError(errors) =>
// BadRequest(JsError.toJson(errors))
// }
// }
// }The __ (double underscore) in Play JSON is the JsPath root — (__ \ "field") creates a path combinator used in Reads and Writes definitions. The and combinator chains multiple path reads into a single case class constructor call. This functional builder pattern is more verbose than Circe's for-comprehension style but makes the field-to-key mapping explicit, which is useful when JSON key names differ from Scala field names: (__ \ "first_name").read[String] maps the first_name JSON key to any field you choose.
JSON Path Traversal and Optional Fields
All three libraries provide cursor or path APIs for navigating JSON without committing to a full type decode. Circe's HCursor and ACursor track traversal history. Play JSON's \ and \\ operators return JsLookupResult which you convert with .as[T], .asOpt[T], or .validate[T]. Spray-JSON provides JsObject.fields and pattern matching on JsValue subtypes. All three approaches handle Option[T] fields — absent or null JSON values become None.
import io.circe._
import io.circe.parser._
import io.circe.syntax._
import io.circe.generic.auto._
val json = parse("""
{
"id": 1,
"profile": {
"name": "Alice",
"bio": null,
"social": {
"twitter": "@alice",
"github": null
}
},
"scores": [98, 85, 92]
}
""").getOrElse(Json.Null)
val cursor = json.hcursor
// ── Safe field access ─────────────────────────────────────────
val id: Either[DecodingFailure, Int] = cursor.downField("id").as[Int]
// Right(1)
val name: Either[DecodingFailure, String] =
cursor.downField("profile").downField("name").as[String]
// Right("Alice")
// ── Null / missing fields with Option ────────────────────────
// bio is null in JSON — decodes to None for Option[String]
val bio: Either[DecodingFailure, Option[String]] =
cursor.downField("profile").downField("bio").as[Option[String]]
// Right(None)
// github is null — same pattern
val github: Either[DecodingFailure, Option[String]] =
cursor.downField("profile").downField("social").downField("github").as[Option[String]]
// Right(None)
// Missing field entirely — also decodes to None for Option[T]
val missing: Either[DecodingFailure, Option[String]] =
cursor.downField("profile").downField("nonExistent").as[Option[String]]
// Right(None) — missing keys are None, not an error
// ── Array traversal ───────────────────────────────────────────
val firstScore: Either[DecodingFailure, Int] =
cursor.downField("scores").downN(0).as[Int]
// Right(98)
val allScores: Either[DecodingFailure, List[Int]] =
cursor.downField("scores").as[List[Int]]
// Right(List(98, 85, 92))
// ── Case class with Option fields ────────────────────────────
case class Social(twitter: Option[String], github: Option[String])
case class Profile(name: String, bio: Option[String], social: Social)
case class UserData(id: Int, profile: Profile, scores: List[Int])
val userData: Either[DecodingFailure, UserData] = decode[UserData](json.noSpaces)
// Right(UserData(1, Profile("Alice", None, Social(Some("@alice"), None)), List(98, 85, 92)))
// ── Encoding: None → null vs None → omit ─────────────────────
val partial = UserData(2, Profile("Bob", None, Social(None, None)), List())
val encoded: Json = partial.asJson
// None fields encode as null by default
// To drop null values entirely:
val compact: Json = encoded.deepDropNullValues
// {"id":2,"profile":{"name":"Bob","social":{}},"scores":[]}The key distinction between a missing JSON key and an explicit null is relevant for PATCH-style APIs: you may want {"bio": null} to mean "clear the bio" and a missing bio key to mean "leave unchanged." Standard Circe auto-derivation treats both as None for Option[T] fields. To distinguish them, use circe-generic-extras with a custom Configuration, or model the field as Option[Option[T]] where Some(None) means explicitly null and None means absent.
Circe vs Spray-JSON vs Play JSON: Choosing the Right Library
The choice between Circe, Spray-JSON, and Play JSON depends primarily on your framework and functional programming preferences. Circe is the community default for new Scala projects and the standard choice with http4s, ZIO HTTP, and Tapir. Spray-JSON is the default for Akka HTTP and requires less setup for basic use cases. Play JSON is tied to the Play Framework ecosystem and is the right choice for Play projects.
// ── Decision matrix ───────────────────────────────────────────
//
// Criterion Circe Spray-JSON Play JSON
// ─────────────────────────────────────────────────────────────
// Error handling Either exceptions JsResult
// Code derivation auto/semi manual macro
// Sealed traits auto manual limited
// Framework integration http4s/ZIO Akka HTTP Play
// Cats integration yes no partial
// Streaming circe-fs2 no no
// Extra config circe-extras limited Json.format
//
// ── Use Circe when: ──────────────────────────────────────────
// - Starting a new project without an existing framework
// - Using http4s, ZIO HTTP, Tapir, or fs2 streaming
// - Need functional error handling with Either/Validated
// - Have complex sealed trait hierarchies
// - Want compile-time derivation with minimal boilerplate
// ── Use Spray-JSON when: ─────────────────────────────────────
// - Already using Akka HTTP and want the built-in integration
// - Simple domain model (< 22 fields per case class)
// - Team prefers explicit format definitions over macros
// - Minimal dependency footprint required (no Cats)
// ── Use Play JSON when: ──────────────────────────────────────
// - Using the Play Framework (it's already on the classpath)
// - Need play-json's combinator Reads/Writes for complex shapes
// - Standalone use: org.playframework %% play-json % "3.x.x"
// ── Version compatibility (Scala 2.13 + Scala 3) ─────────────
// Circe: Scala 2.12, 2.13, 3.x ✓ (0.14.x)
// Spray-JSON: Scala 2.12, 2.13 ✓ (no Scala 3 support as of 2026)
// Play JSON: Scala 2.13, 3.x ✓ (3.x.x under org.playframework)
// ── Interop: use Circe and Spray-JSON in the same project ─────
// If you have Akka HTTP but want Circe codecs:
// libraryDependencies += "de.heikoseeberger" %% "akka-http-circe" % "1.40.0"
// import de.heikoseeberger.akkahttpcirce.FailFastUnmarshaller._
// entity(as[MyCase]) now uses your Circe Decoder[MyCase]
// ── Performance note (rough benchmarks) ──────────────────────
// All three are fast for typical API payloads (< 1 MB).
// For high-throughput scenarios (> 10k req/s, large payloads):
// - Consider jsoniter-scala (fastest JVM JSON library, ~3-5× circe)
// - jsoniter-scala has circe-compatible codec derivation
// - Or use binary formats: MessagePack, Protobuf, Avro
// ── Tapir integration (OpenAPI + JSON) ───────────────────────
// Tapir supports all three via separate integration modules:
// "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion
// "com.softwaremill.sttp.tapir" %% "tapir-json-play" % tapirVersion
// "com.softwaremill.sttp.tapir" %% "tapir-json-spray" % tapirVersionFor Scala 3 projects, Circe 0.14.x and Play JSON 3.x both support Scala 3 natively. Spray-JSON does not have official Scala 3 support as of 2026, making it a Scala 2-only option. If you are building a new Scala 3 service, Circe is the practical default; if you need maximum performance with compile-time code generation and Scala 3 support, jsoniter-scala is worth evaluating — it offers circe-compatible APIs with 3 to 5× faster throughput on large payloads.
FAQ
How do I parse JSON in Scala with Circe?
Add the circe-parser and circe-generic dependencies to your build.sbt: "io.circe" %% "circe-parser" % "0.14.x" and "io.circe" %% "circe-generic" % "0.14.x". Then import io.circe.parser._ and call parse(jsonString) to get Either[ParsingFailure, Json]. For decoding to a case class, also import io.circe.generic.auto._ and call decode[MyCase](jsonString), which returns Either[DecodingFailure, MyCase]. The Either return type forces you to handle the error case without exceptions — use fold, match, or for-comprehensions to extract the result. Circe does not throw on malformed JSON; all errors are encoded as typed values in the Left branch.
How do I auto-derive JSON codecs for case classes in Circe?
Import io.circe.generic.auto._ and Circe will automatically derive Encoder and Decoder instances for any case class whose field types already have codec instances. This derivation happens at compile time using Scala macros — there is zero reflection and zero runtime overhead beyond the resulting generated code. All primitive types (String, Int, Double, Boolean), Option fields, nested case classes, and collections (List, Vector, Map[String, _]) are supported out of the box. If you want to make the derivation explicit and avoid import side-effect surprises, use the @JsonCodec annotation from circe-generic-extras, which generates the codec as a companion object member in 1 annotation instead of 5+ lines of boilerplate. For sealed trait hierarchies, auto._ also derives a discriminated union codec using the class name as the type discriminator.
What is the difference between Circe and Spray-JSON?
Circe is a purely functional JSON library built on Cats — it uses Either for error handling, derives codecs via compile-time macros, and integrates directly with http4s and fs2 streaming. Spray-JSON is a simpler library originally designed for Akka HTTP — it uses explicit RootJsonFormat instances you define via jsonFormat1/jsonFormat2/.../jsonFormat22 helper methods for case classes with 1 to 22 fields, and throws SprayJsonParseException on parse failure rather than returning Either. Circe is the community default for new projects, with more than 2,300 additional GitHub stars compared to Spray-JSON; it offers better type safety and less boilerplate for complex domain models. Spray-JSON is a good choice if you are already using Akka HTTP and want minimal dependencies — Akka HTTP ships with spray-json integration built in. Play JSON is the third major option, specific to the Play Framework, using Reads/Writes/Format type classes derived via Json.reads[T] and Json.writes[T] macros.
How do I handle JSON parsing errors in Scala?
Circe returns Either[ParsingFailure, Json] from parse() and Either[DecodingFailure, T] from decode[T](). Handle errors with pattern matching: result match {`{ case Right(value) => use(value); case Left(error) => handle(error.message) }`}. Use .getOrElse(default) for a fallback, .fold(err => ..., ok => ...) for transforming both branches, or for-comprehension chaining to sequence multiple decode calls. Both ParsingFailure and DecodingFailure extend io.circe.Error which extends Throwable, so you can convert to an exception with .toTry or .toEither.left.map(...). In Play JSON, JsResult (JsSuccess/JsError) serves the same role — json.validate[T] returns JsResult[T], and JsError.toJson(e) converts error details to a JSON response body. Spray-JSON throws exceptions; wrap spray-json parse calls in Try to match Circe-style error handling.
How do I use Play JSON in a Play Framework controller?
In a Play Framework controller, import play.api.libs.json._. Define implicit Format instances using the Json.format[T] macro: implicit val userFormat: Format[User] = Json.format[User]. In the action, parse the request body with request.body.asJson to get Option[JsValue], or use Action(parse.json) to require JSON and get request.body as JsValue directly. Validate with body.validate[User] returning JsResult[User] — on JsSuccess call Ok(Json.toJson(result)), on JsError return BadRequest(JsError.toJson(errors)). Play JSON uses the play-json library which is separate from Play 3.x and can be used independently with the "org.playframework" %% "play-json" % "3.x.x" dependency.
How do I decode a JSON array of case classes in Scala?
With Circe, decode[List[MyCase]](jsonArrayString) decodes a JSON array to a Scala List. If any element fails to decode, the whole result is a Left[DecodingFailure] containing the path and error for the first failure. To collect all errors instead of failing fast, use decodeAccumulating[List[MyCase]](json) which returns ValidatedNel[DecodingFailure, List[MyCase]] — a Cats Validated that accumulates all failures into a NonEmptyList. For streaming large arrays without loading them all into memory, use circe-fs2: Stream.eval(IO(json)).through(stringArrayParser).through(decoder[IO, MyCase]).compile.toList. In Play JSON, Json.parse(s).as[List[User]] or .validate[Seq[User]] decodes JSON arrays, and both require an implicit Reads[User] in scope. Spray-JSON uses myJsonString.parseJson.convertTo[List[MyCase]] with an implicit JsonFormat[MyCase].
How do I handle optional JSON fields in Circe?
Define case class fields as Option[T] and Circe auto-derivation handles the rest — a missing JSON key decodes to None, a null JSON value also decodes to None, and a present value decodes to Some(value). To distinguish between a missing key and an explicit null, use circe-generic-extras and configure the codec with Configuration.default.withDefaults to treat missing keys as None and null as a decoding failure, or vice versa. For custom cursor navigation, HCursor provides .downField("key").as[Option[String]] — if the field is absent, .as[Option[T]] returns Right(None) rather than Left(DecodingFailure). When encoding, fields of type Option[T] that are None are serialized as null by default; to omit them entirely, call .dropNullValues on the resulting Json object.
How do I use Circe with Akka HTTP?
Add the de.heikoseeberger %% akka-http-circe dependency and import de.heikoseeberger.akkahttpcirce.FailFastUnmarshaller._. With that import and Circe encoders/decoders in scope, Akka HTTP's entity(as[MyCase]) unmarshaller automatically decodes the request body JSON to your case class, and complete(StatusCodes.OK, myObject) automatically encodes the response. For manual control, use Unmarshal(entity).to[MyCase] which returns a Future[MyCase]. The akka-http-circe integration handles Content-Type negotiation, charset detection, and streaming. Alternatively, migrate to http4s which has native Circe integration via http4s-circe: use req.decodeJson[MyCase] in routes and Ok(myObject.asJson) for responses — a more composable approach that avoids the third-party bridge dependency.
Further reading and primary sources
- Circe Documentation — Official Circe docs covering auto/semi-auto derivation, cursors, codec composition, and fs2 streaming
- Spray-JSON GitHub — Spray-JSON source code, format helpers, and Akka HTTP integration examples
- Play JSON Documentation — Play Framework JSON guide covering Reads, Writes, Format, JsValue navigation, and controller patterns
- Akka HTTP JSON Support — Akka HTTP JSON marshalling/unmarshalling with spray-json and third-party libraries
- circe-generic-extras — Circe extras for snake_case field naming, discriminator customization, and default value handling