Scala JSON: Circe, Play JSON, spray-json & Akka HTTP
Last updated:
Scala's most popular JSON library is Circe — it derives Encoder and Decoder instances automatically for case classes via import io.circe.generic.auto._, parses JSON with io.circe.parser.parse, and integrates with Cats for functional error handling. io.circe.parser.decode[User](jsonString) returns Either[Error, User] — the Left contains a DecodingFailure with cursor history showing exactly which field failed. Circe's HCursor enables safe navigation: cursor.downField("address").downField("city").as[String] returns Either[DecodingFailure, String]. This guide covers Circe automatic/semi-automatic derivation, custom Encoder/Decoder instances, HCursor traversal, Play JSON Reads/Writes macros, spray-json JsonFormat, and Akka HTTP / http4s JSON integration.
Circe Automatic Derivation with io.circe.generic.auto
Circe derives Encoder[T] and Decoder[T] instances at compile time using Scala macros — no reflection, no runtime overhead. Add import io.circe.generic.auto._ and Circe automatically generates instances for any case class whose field types also have Encoder/Decoder instances. Built-in instances exist for all Scala primitives, Option, List, Map[String, _], and common Java types. Semi-automatic derivation (import io.circe.generic.semiauto._) gives more control and faster compile times for large projects — you explicitly call deriveDecoder[T] and deriveEncoder[T] per type.
// build.sbt dependencies
libraryDependencies ++= Seq(
"io.circe" %% "circe-core" % "0.14.7",
"io.circe" %% "circe-generic" % "0.14.7",
"io.circe" %% "circe-parser" % "0.14.7"
)
// ── Automatic derivation ─────────────────────────────────────────────
import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
import io.circe.syntax._
case class Address(street: String, city: String, country: String)
case class User(id: Long, name: String, email: String, address: Address, tags: List[String])
// Encoding: case class -> JSON string
val user = User(1L, "Alice", "alice@example.com",
Address("123 Main St", "Berlin", "DE"), List("admin", "editor"))
val json: String = user.asJson.noSpaces
// {"id":1,"name":"Alice","email":"alice@example.com",
// "address":{"street":"123 Main St","city":"Berlin","country":"DE"},
// "tags":["admin","editor"]}
val prettyJson: String = user.asJson.spaces2 // 2-space indent
// Encoding a collection
val users = List(user, user.copy(id = 2L, name = "Bob"))
val usersJson: String = users.asJson.noSpaces
// ── Semi-automatic derivation ────────────────────────────────────────
import io.circe.generic.semiauto._
case class Product(id: Long, name: String, priceInCents: Long)
object Product {
// Explicit implicit vals — faster compile, clearer scope
implicit val decoder: Decoder[Product] = deriveDecoder[Product]
implicit val encoder: Encoder[Product] = deriveEncoder[Product]
}
val product = Product(42L, "Widget", 1999L)
println(product.asJson.noSpaces)
// {"id":42,"name":"Widget","priceInCents":1999}
// ── Sealed traits / ADTs ─────────────────────────────────────────────
import io.circe.generic.auto._
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
// Circe encodes ADTs as {"Circle": {"radius": 5.0}} by default
val shape: Shape = Circle(5.0)
println(shape.asJson.noSpaces)
// {"Circle":{"radius":5.0}}
// ── Optional and nullable fields ─────────────────────────────────────
case class Config(host: String, port: Int, debug: Option[Boolean])
val cfg = Config("localhost", 8080, None)
println(cfg.asJson.noSpaces)
// {"host":"localhost","port":8080,"debug":null}
// To omit None fields entirely, use dropNullValues
println(cfg.asJson.dropNullValues.noSpaces)
// {"host":"localhost","port":8080}Automatic derivation is convenient for prototyping and small projects, but prefer semi-automatic derivation in production codebases — placing implicit val decoder in the companion object avoids implicit ambiguity, makes derivation explicit, and reduces compilation time on large case class hierarchies. For ADT encoding, the default Circe format wraps each subtype in a JSON object keyed by the subtype name. Use circe-generic-extras with @JsonCodec and configuration to control discriminator field naming and unwrapping behavior.
Parsing and Decoding: parse() and decode[T]()
Circe provides two levels of JSON ingestion: parse(str) converts a raw string into Circe's Json AST (Either[ParsingFailure, Json]), while decode[T](str) combines parsing and decoding into one step returning Either[io.circe.Error, T]. Use parse when you need the raw AST for inspection or transformation before decoding; use decode when you know the target type upfront.
import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
// ── parse(): String -> Either[ParsingFailure, Json] ──────────────────
val jsonStr = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""
parse(jsonStr) match {
case Right(json) =>
println(json) // Json AST
println(json.spaces2) // pretty-printed string
case Left(err) =>
println(s"Parse error: ${err.message}")
}
// ── decode[T](): String -> Either[io.circe.Error, T] ─────────────────
case class User(id: Long, name: String, email: String)
decode[User](jsonStr) match {
case Right(user) => println(s"Decoded: ${user.name}")
case Left(err) => println(s"Error: ${err.getMessage}")
}
// ── Distinguishing error types ────────────────────────────────────────
import io.circe.{DecodingFailure, ParsingFailure}
def parseUser(raw: String): Either[String, User] =
decode[User](raw).left.map {
case pf: ParsingFailure => s"Invalid JSON syntax: ${pf.message}"
case df: DecodingFailure =>
val path = df.history.mkString(" -> ")
s"Type mismatch at $path: ${df.message}"
}
// ── Decoding nested structures ────────────────────────────────────────
case class Address(city: String, country: String)
case class UserWithAddress(id: Long, name: String, address: Address)
val nested = """
{
"id": 2,
"name": "Bob",
"address": {"city": "Paris", "country": "FR"}
}
"""
decode[UserWithAddress](nested) match {
case Right(u) => println(u.address.city) // Paris
case Left(e) => println(e.getMessage)
}
// ── Decoding lists ────────────────────────────────────────────────────
val usersJson = """[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]"""
case class SimpleUser(id: Long, name: String)
decode[List[SimpleUser]](usersJson).foreach { users =>
users.foreach(u => println(u.name))
}
// ── Using .toOption and .toTry ────────────────────────────────────────
val userOpt: Option[User] = decode[User](jsonStr).toOption
val userTry: scala.util.Try[User] = decode[User](jsonStr).toTry
// ── Accumulating errors with Decoder.accumulateErrors ────────────────
import cats.syntax.all._
val result = (decode[User](jsonStr), decode[SimpleUser](jsonStr))
.mapN { (u, s) => (u, s) } // requires both to succeedThe io.circe.Error type is a sealed trait with two cases: ParsingFailure wraps a jawn.ParseException and signals malformed JSON syntax, while DecodingFailure signals valid JSON that doesn't match the expected type — it carries a CursorHistory (list of CursorOp) showing the exact navigation path where decoding failed. Always prefer decode[T] over parse followed by manual cursor navigation when the target type is known at compile time.
HCursor: Safe JSON Traversal
HCursor (History Cursor) is Circe's primary API for navigating and extracting values from a Json AST without throwing exceptions. It maintains a navigation history so that failed traversals include the full path to the failing field in the DecodingFailure error. Every navigation method returns an ACursor — you chain navigations freely and only materialize the result with .as[T] or .get[T]("field").
import io.circe._
import io.circe.parser._
val json = parse("""
{
"user": {
"id": 42,
"name": "Alice",
"address": {
"city": "Berlin",
"country": "DE",
"zip": "10115"
},
"scores": [98, 87, 95]
}
}
""").getOrElse(Json.Null)
val cursor: HCursor = json.hcursor
// ── downField: navigate into an object field ─────────────────────────
val cityResult: Either[DecodingFailure, String] =
cursor.downField("user").downField("address").downField("city").as[String]
// Right("Berlin")
// ── get[T]: shorthand for downField("key").as[T] ─────────────────────
val nameResult: Either[DecodingFailure, String] =
cursor.downField("user").get[String]("name")
// Right("Alice")
// ── downArray + right: navigate arrays ───────────────────────────────
val firstScore: Either[DecodingFailure, Int] =
cursor.downField("user").downField("scores").downArray.as[Int]
// Right(98)
val secondScore: Either[DecodingFailure, Int] =
cursor.downField("user").downField("scores").downArray.right.as[Int]
// Right(87)
// ── Decode entire field as a collection ──────────────────────────────
val allScores: Either[DecodingFailure, List[Int]] =
cursor.downField("user").downField("scores").as[List[Int]]
// Right(List(98, 87, 95))
// ── Optional fields: .as[Option[T]] ──────────────────────────────────
val phoneResult: Either[DecodingFailure, Option[String]] =
cursor.downField("user").get[Option[String]]("phone")
// Right(None) — field absent -> None, not a failure
// ── Error with history ────────────────────────────────────────────────
val badPath: Either[DecodingFailure, String] =
cursor.downField("user").downField("nonexistent").as[String]
badPath match {
case Left(df) =>
println(df.message) // "Attempt to decode value on failed cursor"
println(df.history) // List(DownField("nonexistent"), DownField("user"))
case Right(v) => println(v)
}
// ── Composing traversal with for-comprehension ────────────────────────
val combined: Either[DecodingFailure, (Long, String, String)] =
for {
id <- cursor.downField("user").get[Long]("id")
name <- cursor.downField("user").get[String]("name")
city <- cursor.downField("user").downField("address").get[String]("city")
} yield (id, name, city)
// Right((42L, "Alice", "Berlin"))
// ── Modifying a field and re-encoding ────────────────────────────────
val modified: Json =
cursor.downField("user").downField("address")
.withFocus(_.mapObject(_.add("zip", Json.fromString("10117"))))
.top
.getOrElse(Json.Null)
println(modified.noSpaces)Use .as[Option[T]] rather than .as[T] for fields that may be absent — this returns Right(None) when the field is missing instead of a DecodingFailure. The .withFocus method applies a transformation to the current focus and returns a new cursor pointing to the modified value — call .top to navigate back to the root after modification. For building custom Decoder instances, use Decoder.instance { cursor => cursor.downField(...).as[T] } to hand-write traversal logic.
Custom Encoder and Decoder Instances
When automatic derivation doesn't fit — non-standard field names, custom types like java.time.Instant, value classes, or sealed traits with custom discriminators — define Encoder[T] and Decoder[T] instances explicitly. Place them in the companion object of the type so they are always in implicit scope without explicit imports. Custom instances compose: a custom Encoder[UserId] is automatically used whenever Circe encodes a case class containing a UserId field.
import io.circe._
import io.circe.syntax._
import java.time.Instant
import java.util.UUID
// ── Custom Encoder/Decoder for java.time.Instant ─────────────────────
implicit val encodeInstant: Encoder[Instant] =
Encoder.instance(instant => Json.fromString(instant.toString))
implicit val decodeInstant: Decoder[Instant] =
Decoder.instance { cursor =>
cursor.as[String].flatMap { str =>
scala.util.Try(Instant.parse(str))
.toEither
.left.map(e => DecodingFailure(e.getMessage, cursor.history))
}
}
// circe-java8 module provides these automatically:
// "io.circe" %% "circe-java8" % "0.14.7"
// ── Value class: wraps a Long for type safety ─────────────────────────
case class UserId(value: Long) extends AnyVal
object UserId {
implicit val encoder: Encoder[UserId] = Encoder[Long].contramap(_.value)
implicit val decoder: Decoder[UserId] = Decoder[Long].map(UserId(_))
}
// ── Custom field names: snake_case JSON, camelCase Scala ─────────────
// Option 1: circe-generic-extras with configuration
import io.circe.generic.extras._
import io.circe.generic.extras.semiauto._
@ConfiguredJsonCodec
case class ApiResponse(userId: Long, firstName: String, createdAt: Instant)
object ApiResponse {
implicit val config: Configuration =
Configuration.default.withSnakeCaseMemberNames
// JSON: {"user_id":1,"first_name":"Alice","created_at":"2026-05-20T..."}
}
// Option 2: fully manual encoder/decoder
case class Event(eventId: UUID, eventType: String, occurredAt: Instant)
object Event {
implicit val encoder: Encoder[Event] = Encoder.instance { e =>
Json.obj(
"event_id" -> e.eventId.toString.asJson,
"event_type" -> e.eventType.asJson,
"occurred_at" -> e.occurredAt.asJson
)
}
implicit val decoder: Decoder[Event] = Decoder.instance { cursor =>
for {
eventId <- cursor.get[String]("event_id").map(UUID.fromString)
eventType <- cursor.get[String]("event_type")
occurredAt <- cursor.get[Instant]("occurred_at")
} yield Event(eventId, eventType, occurredAt)
}
}
// ── Encoder via forProductN ───────────────────────────────────────────
case class Point(x: Double, y: Double)
object Point {
implicit val encoder: Encoder[Point] =
Encoder.forProduct2("x", "y")(p => (p.x, p.y))
implicit val decoder: Decoder[Point] =
Decoder.forProduct2("x", "y")(Point.apply)
}
// ── Sealed trait with custom discriminator ────────────────────────────
sealed trait Notification
case class Email(to: String, subject: String) extends Notification
case class Push(deviceId: String, body: String) extends Notification
object Notification {
implicit val encoder: Encoder[Notification] = Encoder.instance {
case e: Email => Json.obj("type" -> "email".asJson, "to" -> e.to.asJson, "subject" -> e.subject.asJson)
case p: Push => Json.obj("type" -> "push".asJson, "deviceId" -> p.deviceId.asJson, "body" -> p.body.asJson)
}
implicit val decoder: Decoder[Notification] = Decoder.instance { cursor =>
cursor.get[String]("type").flatMap {
case "email" =>
for { to <- cursor.get[String]("to"); s <- cursor.get[String]("subject") }
yield Email(to, s)
case "push" =>
for { d <- cursor.get[String]("deviceId"); b <- cursor.get[String]("body") }
yield Push(d, b)
case t => Left(DecodingFailure(s"Unknown notification type: $t", cursor.history))
}
}
}The Encoder.contramap and Decoder.map methods are the idiomatic way to derive instances for wrapper types — they reuse existing instances rather than building JSON from scratch. For snake_case field mapping across an entire project, add the circe-generic-extras module and configure Configuration.default.withSnakeCaseMemberNames globally in a shared implicit scope rather than per-type.
Play JSON: Reads, Writes, and Format Macros
Play JSON uses type classes Reads[T] (deserialization), Writes[T] (serialization), and Format[T] (both) — generated by macros at compile time. Play JSON is available as a standalone library (org.playframework:play-json) independent of the full Play Framework. Its JsResult type (JsSuccess/JsError) parallels Circe's Either but carries multiple validation errors simultaneously, making it well-suited for user input validation.
// build.sbt
libraryDependencies += "org.playframework" %% "play-json" % "3.0.3"
import play.api.libs.json._
import play.api.libs.functional.syntax._
// ── Macro-derived Format ──────────────────────────────────────────────
case class User(id: Long, name: String, email: String)
object User {
implicit val format: Format[User] = Json.format[User]
// Or separately:
// implicit val reads: Reads[User] = Json.reads[User]
// implicit val writes: Writes[User] = Json.writes[User]
}
// Parsing: String -> JsValue -> T
val jsonStr = """{"id":1,"name":"Alice","email":"alice@example.com"}"""
val jsValue: JsValue = Json.parse(jsonStr)
// validate[T]: returns JsResult[T]
jsValue.validate[User] match {
case JsSuccess(user, _) => println(s"Got user: ${user.name}")
case JsError(errors) =>
errors.foreach { case (path, errs) =>
println(s"$path: ${errs.map(_.message).mkString(", ")}")
}
}
// as[T]: throws JsResultException on failure
val user: User = jsValue.as[User]
// asOpt[T]: returns Option[T], None on failure
val userOpt: Option[User] = jsValue.asOpt[User]
// ── Serializing: T -> JsValue -> String ──────────────────────────────
val newUser = User(2L, "Bob", "bob@example.com")
val js: JsValue = Json.toJson(newUser)
val str: String = Json.stringify(js) // compact
val pretty: String = Json.prettyPrint(js) // indented
// ── Custom Reads with validation ──────────────────────────────────────
case class CreateUserRequest(name: String, email: String, age: Int)
object CreateUserRequest {
implicit val reads: Reads[CreateUserRequest] = (
(JsPath "name").read[String](minLength[String](1) keepAnd maxLength[String](100)) and
(JsPath "email").read[String](email) and
(JsPath "age").read[Int](min(18) keepAnd max(120))
)(CreateUserRequest.apply _)
}
// ── Custom Writes with field renaming ────────────────────────────────
case class ApiUser(userId: Long, displayName: String)
object ApiUser {
implicit val writes: Writes[ApiUser] = (
(JsPath "user_id").write[Long] and
(JsPath "display_name").write[String]
)(u => (u.userId, u.displayName))
}
// ── Nested objects and optional fields ────────────────────────────────
case class Address(city: String, country: String)
case class Profile(user: User, address: Option[Address])
object Address { implicit val format: Format[Address] = Json.format[Address] }
object Profile { implicit val format: Format[Profile] = Json.format[Profile] }
// ── Reading from arbitrary JsValue paths ─────────────────────────────
val nested = Json.parse("""{"data":{"user":{"id":5,"name":"Eve"}}}""")
val extractedId: JsResult[Long] = (nested "data" "user" "id").validate[Long]
// JsSuccess(5L, /data/user/id)
// ── JsObject construction ─────────────────────────────────────────────
val manual: JsObject = Json.obj(
"status" -> "ok",
"count" -> 42,
"items" -> Json.arr("a", "b", "c"),
"meta" -> Json.obj("page" -> 1, "per_page" -> 20)
)Play JSON's JsError accumulates all validation errors across all fields simultaneously — unlike Circe's DecodingFailure which short-circuits at the first error. This makes Play JSON particularly suited for validating user-submitted forms where you want to report all field errors at once. The keepAnd combinator chains validation rules on a single field; the and combinator (from FunctionalBuilder) combines reads across multiple fields into a single Reads[T].
spray-json: JsonFormat and DefaultJsonProtocol
spray-json is a lightweight, zero-dependency Scala JSON library commonly used in Akka HTTP applications. It uses a JsonFormat[T] type class with write(value) and read(json) methods. Extend DefaultJsonProtocol and call jsonFormatN macros (where N is the number of fields) to derive formats for case classes. spray-json throws DeserializationException on failure — no Either wrapping by default.
// build.sbt
libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
import spray.json._
import DefaultJsonProtocol._
// ── jsonFormatN: derive format for a case class ───────────────────────
case class User(id: Long, name: String, email: String)
case class Address(city: String, country: String)
case class UserWithAddress(id: Long, name: String, address: Address)
// Order of implicit vals matters: Address must be defined before UserWithAddress
implicit val addressFormat: JsonFormat[Address] = jsonFormat2(Address.apply)
implicit val userFormat: JsonFormat[User] = jsonFormat3(User.apply)
implicit val userAddrFmt: JsonFormat[UserWithAddress] = jsonFormat3(UserWithAddress.apply)
// ── Encoding: toJson / compactPrint / prettyPrint ─────────────────────
val user = User(1L, "Alice", "alice@example.com")
val jsValue: JsValue = user.toJson
val compact: String = jsValue.compactPrint
val pretty: String = jsValue.prettyPrint
println(compact)
// {"id":1,"name":"Alice","email":"alice@example.com"}
// ── Decoding: parseJson + convertTo[T] ────────────────────────────────
val jsonStr = """{"id":2,"name":"Bob","email":"bob@example.com"}"""
val parsed: JsValue = jsonStr.parseJson
val decoded: User = parsed.convertTo[User]
// Safe decoding with Try
import scala.util.Try
val result: Try[User] = Try(jsonStr.parseJson.convertTo[User])
// ── Custom JsonFormat ─────────────────────────────────────────────────
import java.time.Instant
implicit object InstantFormat extends JsonFormat[Instant] {
def write(instant: Instant): JsValue = JsString(instant.toString)
def read(json: JsValue): Instant = json match {
case JsString(str) => Instant.parse(str)
case other => deserializationError(s"Expected ISO-8601 string, got: $other")
}
}
// ── Sealed trait / ADT with RootJsonFormat ────────────────────────────
sealed trait Status
case object Active extends Status
case object Inactive extends Status
implicit object StatusFormat extends RootJsonFormat[Status] {
def write(s: Status): JsValue = s match {
case Active => JsString("active")
case Inactive => JsString("inactive")
}
def read(json: JsValue): Status = json match {
case JsString("active") => Active
case JsString("inactive") => Inactive
case other => deserializationError(s"Unknown status: $other")
}
}
// ── JsObject / JsArray construction ──────────────────────────────────
val manual: JsObject = JsObject(
"status" -> JsString("ok"),
"count" -> JsNumber(42),
"items" -> JsArray(JsString("a"), JsString("b"))
)
// Field access from JsObject
val fields: Map[String, JsValue] = manual.fields
val count: JsValue = fields("count") // JsNumber(42)spray-json uses RootJsonFormat[T] (extends JsonFormat[T]) to mark types that can appear as top-level JSON values — the distinction matters in Akka HTTP, which requires a RootJsonFormat for entity marshalling. Unlike Circe, spray-json formats are not implicitly derived for sealed trait hierarchies — you must write the read and write implementations manually. The library hasn't had a major release since 2017 but remains stable and widely used in Akka HTTP projects.
Akka HTTP and http4s JSON Integration
Both Akka HTTP and http4s have dedicated integration modules for Circe and spray-json that provide implicit EntityEncoder/EntityDecoder (http4s) or Marshaller/Unmarshaller (Akka HTTP) instances. Once the integration import is in scope, routes automatically encode responses and decode request bodies using the JSON library of your choice — no manual Json.stringify or Json.parse calls needed.
// ── Akka HTTP with spray-json (built-in) ─────────────────────────────
// build.sbt: "com.typesafe.akka" %% "akka-http-spray-json" % "10.5.3"
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
import spray.json._
import DefaultJsonProtocol._
case class CreateUserRequest(name: String, email: String)
case class UserResponse(id: Long, name: String, email: String)
implicit val createReqFmt: RootJsonFormat[CreateUserRequest] = jsonFormat2(CreateUserRequest.apply)
implicit val userRespFmt: RootJsonFormat[UserResponse] = jsonFormat3(UserResponse.apply)
val route =
path("users") {
post {
entity(as[CreateUserRequest]) { req =>
// req is already decoded
val response = UserResponse(1L, req.name, req.email)
complete(response) // automatically encoded to JSON, Content-Type: application/json
}
} ~
get {
complete(List(UserResponse(1L, "Alice", "alice@example.com")))
}
}
// ── Akka HTTP with Circe (akka-http-circe) ───────────────────────────
// build.sbt: "de.heikoseeberger" %% "akka-http-circe" % "1.39.2"
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import io.circe.generic.auto._
case class Item(id: Long, name: String, price: Double)
val circeRoute =
path("items" / LongNumber) { id =>
get {
complete(Item(id, "Widget", 19.99)) // Encoder[Item] from circe.generic.auto
}
} ~
path("items") {
post {
entity(as[Item]) { item => // Decoder[Item] from circe.generic.auto
complete(item.copy(id = 42L))
}
}
}
// ── http4s with Circe ─────────────────────────────────────────────────
// build.sbt:
// "org.http4s" %% "http4s-ember-server" % "0.23.27",
// "org.http4s" %% "http4s-circe" % "0.23.27",
// "io.circe" %% "circe-generic" % "0.14.7"
import org.http4s._
import org.http4s.circe.CirceEntityCodec._ // auto EntityEncoder + EntityDecoder
import io.circe.generic.auto._
import cats.effect.IO
case class UserDto(id: Long, name: String)
val httpRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] {
case GET -> Root / "users" / LongNumber(id) =>
Ok(UserDto(id, "Alice")) // EntityEncoder[IO, UserDto] from CirceEntityCodec
case req @ POST -> Root / "users" =>
for {
dto <- req.as[UserDto] // EntityDecoder[IO, UserDto] from CirceEntityCodec
resp <- Created(dto.copy(id = 99L))
} yield resp
}
// ── Error handling in Akka HTTP Circe routes ──────────────────────────
import akka.http.scaladsl.model.StatusCodes
import de.heikoseeberger.akkahttpcirce.ErrorAccumulatingCirceSupport._
val safeRoute =
path("users") {
post {
entity(as[Item]) { item =>
complete(item)
} ~
// Decoding failure returns 400 automatically with ErrorAccumulatingCirceSupport
complete(StatusCodes.BadRequest -> "Invalid request body")
}
}For Akka HTTP with Circe, choose FailFastCirceSupport for performance-critical paths (stops at first decode error) and ErrorAccumulatingCirceSupport for user-facing APIs that should report all validation problems. In http4s, CirceEntityCodec._ provides both EntityEncoder and EntityDecoder in a single import — use CirceEntityEncoder._ or CirceEntityDecoder._ separately if you need only one direction. Both integrations set Content-Type: application/json; charset=UTF-8 automatically on responses.
Key Terms
- Circe
- The most widely used JSON library for Scala, built on Cats type classes. Circe derives
Encoder[T]andDecoder[T]instances at compile time using Scala macros (viacirce-generic), eliminating runtime reflection. Its core type isJson— an immutable AST — navigated viaHCursor. All decode operations returnEither[io.circe.Error, T], making error handling explicit and composable withcats.syntax.either._. Circe integrates with Akka HTTP (akka-http-circe), http4s (http4s-circe), and fs2/ZIO streaming ecosystems. Available for Scala 2.12, 2.13, and Scala 3 (0.14.x). - HCursor
- Circe's History Cursor — a zipper-like structure for navigating a
JsonAST while maintaining a complete history of navigation operations. Every call todownField,downArray,left,right, oruprecords aCursorOpin the history. When.as[T]fails, the resultingDecodingFailureincludes the fullList[CursorOp]showing the exact field path where decoding broke down — for exampleList(DownField("city"), DownField("address")). This precise error reporting is one of Circe's key advantages over alternatives that return generic error messages.HCursorimplementsACursor, the abstract cursor trait. - Encoder / Decoder
- The core Circe type classes for JSON serialization and deserialization.
Encoder[T]has a single methodapply(value: T): Json— it converts a Scala value to a CirceJsonAST.Decoder[T]hasapply(cursor: HCursor): Decoder.Result[T]whereDecoder.Result[T]is an alias forEither[DecodingFailure, T]. Both are contravariant/covariant respectively and supportcontramap/mapfor deriving instances from existing ones. They are resolved implicitly at compile time — if no instance is found, the compiler error indicates the missing type, not a runtime failure.Codec[T]combines both into a single instance. - Play JSON Reads / Writes
- The Play JSON type classes for JSON deserialization and serialization.
Reads[T]definesreads(json: JsValue): JsResult[T]— it validates and converts aJsValuetoT, accumulating all errors intoJsErrorrather than failing fast.Writes[T]defineswrites(value: T): JsValue.Format[T]extends both. Generated by macros (Json.reads[T],Json.writes[T],Json.format[T]) or hand-written using the combinator DSL withJsPathand theand/keepAndoperators fromplay.api.libs.functional.syntax._. Reads supports constraint combinators:min,max,minLength,email, and custom validators via.filter. - spray-json JsonFormat
- The spray-json type class combining serialization (
write(value: T): JsValue) and deserialization (read(json: JsValue): T) in a single trait. ExtendDefaultJsonProtocoland calljsonFormat1throughjsonFormat22to derive formats for case classes — the N indicates the number of constructor parameters.RootJsonFormat[T]extendsJsonFormat[T]and marks types suitable as top-level JSON documents, required by Akka HTTP marshallers. Custom formats implementRootJsonFormat[T]directly. Unlike Circe, spray-json does not useEither— decoding failures throwDeserializationException, which Akka HTTP converts to a 400 response. - Either[Error, T]
- Scala's standard right-biased sum type used by Circe as the return type for all decode and parse operations.
Right[T]contains the successfully decoded value;Left[io.circe.Error]contains the failure, which is either aParsingFailure(invalid JSON syntax) or aDecodingFailure(valid JSON, wrong structure).Eitheris a monad — you can chain operations withflatMap, transform values withmap, and sequence multiple decodes with for-comprehensions.cats.syntax.either._adds convenience methods:.getOrElse,.toOption,.toTry,.leftMap. In Akka HTTP routes, pattern matching on theEitherfromdecode[T]allows returning appropriate HTTP status codes for parse vs. decode failures.
FAQ
What is the best JSON library for Scala?
Circe is the most popular choice in 2024-2025 for most Scala projects — it derives Encoder/Decoder at compile time with no runtime reflection, integrates naturally with Cats Either, and benchmarks at ~150 MB/s decoding throughput. Use Play JSON for Play Framework applications where its Reads/Writes macros and JsResult error accumulation are deeply integrated with the framework. Use spray-json for Akka HTTP projects that predate Circe integration or need a zero-dependency solution. For Scala 3 and the ZIO ecosystem, zio-json offers faster derivation via Scala 3 macros. For JSON performance-critical workloads, benchmark your specific payload sizes — library throughput varies significantly by data shape.
How do I parse JSON in Scala with Circe?
Import io.circe.parser._ and call parse(jsonString), which returns Either[ParsingFailure, Json]. For direct decoding to a type, use decode[T](jsonString) which returns Either[io.circe.Error, T] and requires an implicit Decoder[T] — add import io.circe.generic.auto._ to derive one for case classes. Pattern match: decode[User](str) match { case Right(u) => u; case Left(e) => println(e.getMessage) }. The io.circe.Error sealed trait has two subtypes: ParsingFailure (bad JSON syntax) and DecodingFailure (wrong JSON structure or type). Use .toOption or .toTry for simpler error handling when you don't need to distinguish error types.
How do I decode a JSON string to a case class in Scala?
With Circe: add import io.circe.generic.auto._ and import io.circe.parser._, then call decode[YourCaseClass](jsonString). The macro derives a Decoder[YourCaseClass] at compile time by matching JSON field names to case class field names — fields must match exactly, including case. The return type is Either[io.circe.Error, YourCaseClass]. For nested case classes, Circe recursively derives decoders for all field types. For semi-automatic derivation (better for large projects), replace generic.auto with generic.semiauto and add explicit implicit val decoder: Decoder[T] = deriveDecoder[T] in the companion object. With Play JSON, use Json.parse(jsonString).as[T] after defining implicit val reads: Reads[T] = Json.reads[T].
How do I handle JSON parsing errors in Scala?
In Circe, decode[T](json) returns Either[io.circe.Error, T] — match on Left(err) where err is either a ParsingFailure (bad JSON syntax) or DecodingFailure (wrong structure). A DecodingFailure has .message (human-readable description) and .history (a List[CursorOp] showing the navigation path to the failing field). In Play JSON, jsValue.validate[T] returns JsSuccess(value, path) or JsError(errors) where errors is a sequence of (JsPath, Seq[JsonValidationError]) pairs — one entry per failing field. In spray-json, parseJson.convertTo[T] throws DeserializationException on failure — wrap in scala.util.Try for safe handling. Avoid .as[T] (Circe/cursor.as[T]) or Play JSON's .as[T] in production code — they throw exceptions.
What is HCursor in Circe?
HCursor is Circe's type-safe JSON navigation API — a zipper over a Json AST that tracks cursor history. Obtain one from any Json value with json.hcursor. Navigate with downField("key") (into an object field), downArray (into first array element), right/left (across array elements), and up (back to parent). Materialize values with .as[T] or .get[T]("fieldName"). Every step is safe — if a field is missing or the JSON type is wrong, the cursor returns a failed ACursor that carries the error until .as[T] is called, at which point it returns a Left(DecodingFailure) with the full navigation history. This makes debugging JSON shape mismatches precise: the error tells you exactly which field path failed.
How do I use Play JSON in Scala?
Add org.playframework:play-json to your build. Define implicit val format: Format[T] = Json.format[T] in the companion object (or separate Reads[T] and Writes[T]). Parse with Json.parse(jsonString) (returns JsValue, throws on invalid syntax — wrap in Try for safety). Decode with jsValue.validate[T] (returns JsResult[T] — safe) or jsValue.as[T] (throws on failure). Encode with Json.toJson(value) (returns JsValue) and Json.stringify(jsValue) (returns String). Navigate nested JSON with the \ operator: (json \ "user" \ "name").validate[String]. Use the combinator DSL in play.api.libs.functional.syntax._ to write custom Reads with field validation, renaming, and transformation. See also: JSON API design patterns for structuring Play JSON responses.
How do I create a custom JSON encoder in Scala?
In Circe, use Encoder.instance[T]: implicit val enc: Encoder[Instant] = Encoder.instance(i => Json.fromString(i.toString)). For wrapper types, use contramap: implicit val enc: Encoder[UserId] = Encoder[Long].contramap(_.value) — this reuses the existing Long encoder without building JSON manually. For multi-field case classes with custom field names, use Encoder.forProduct3("field_a", "field_b", "field_c")(t => (t.a, t.b, t.c)). In Play JSON, define implicit val writes: Writes[T] = Writes(v => JsString(v.toString)) for simple types, or use the combinator DSL with JsPath \ "field_name" and Writes[FieldType] for multi-field types. Place custom encoder instances in the companion object of the type so they are always in scope when Circe needs to encode a case class containing that type as a field.
How do I integrate Circe with Akka HTTP?
Add de.heikoseeberger:akka-http-circe to your build and import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._ (or ErrorAccumulatingCirceSupport._). With that import in scope, Akka HTTP routes automatically marshal and unmarshal request/response bodies using your implicit Circe Encoder[T] and Decoder[T] instances. Use entity(as[RequestType]) { req => ... } to decode request bodies and complete(responseValue) to encode responses — the library sets Content-Type: application/json automatically. Choose FailFastCirceSupport for performance (stops at first decode error) or ErrorAccumulatingCirceSupport for APIs that should return all validation errors together. For http4s, use org.http4s:http4s-circe with import org.http4s.circe.CirceEntityCodec._. See also: TypeScript JSON patterns for client-side integration.
Further reading and primary sources
- Circe Documentation — Official Circe documentation covering automatic derivation, custom codecs, HCursor navigation, and integrations
- Circe GitHub Repository — Circe source, changelog, and module list including circe-generic-extras and circe-java8
- Play JSON Documentation — Play Framework JSON documentation: Reads/Writes macros, JsResult, JsPath combinators, and custom formats
- spray-json README — spray-json JsonFormat, DefaultJsonProtocol, and RootJsonFormat reference with Akka HTTP examples
- akka-http-circe Integration — FailFastCirceSupport and ErrorAccumulatingCirceSupport for Akka HTTP Circe marshalling