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 succeed

The 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] and Decoder[T] instances at compile time using Scala macros (via circe-generic), eliminating runtime reflection. Its core type is Json — an immutable AST — navigated via HCursor. All decode operations return Either[io.circe.Error, T], making error handling explicit and composable with cats.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 Json AST while maintaining a complete history of navigation operations. Every call to downField, downArray, left, right, or up records a CursorOp in the history. When .as[T] fails, the resulting DecodingFailure includes the full List[CursorOp] showing the exact field path where decoding broke down — for example List(DownField("city"), DownField("address")). This precise error reporting is one of Circe's key advantages over alternatives that return generic error messages. HCursor implements ACursor, the abstract cursor trait.
Encoder / Decoder
The core Circe type classes for JSON serialization and deserialization. Encoder[T] has a single method apply(value: T): Json — it converts a Scala value to a Circe Json AST. Decoder[T] has apply(cursor: HCursor): Decoder.Result[T] where Decoder.Result[T] is an alias for Either[DecodingFailure, T]. Both are contravariant/covariant respectively and support contramap/map for 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] defines reads(json: JsValue): JsResult[T] — it validates and converts a JsValue to T, accumulating all errors into JsError rather than failing fast. Writes[T] defines writes(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 with JsPath and the and/keepAnd operators from play.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. Extend DefaultJsonProtocol and call jsonFormat1 through jsonFormat22 to derive formats for case classes — the N indicates the number of constructor parameters. RootJsonFormat[T] extends JsonFormat[T] and marks types suitable as top-level JSON documents, required by Akka HTTP marshallers. Custom formats implement RootJsonFormat[T] directly. Unlike Circe, spray-json does not use Either — decoding failures throw DeserializationException, 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 a ParsingFailure (invalid JSON syntax) or a DecodingFailure (valid JSON, wrong structure). Either is a monad — you can chain operations with flatMap, transform values with map, and sequence multiple decodes with for-comprehensions. cats.syntax.either._ adds convenience methods: .getOrElse, .toOption, .toTry, .leftMap. In Akka HTTP routes, pattern matching on the Either from decode[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 DocumentationOfficial Circe documentation covering automatic derivation, custom codecs, HCursor navigation, and integrations
  • Circe GitHub RepositoryCirce source, changelog, and module list including circe-generic-extras and circe-java8
  • Play JSON DocumentationPlay Framework JSON documentation: Reads/Writes macros, JsResult, JsPath combinators, and custom formats
  • spray-json READMEspray-json JsonFormat, DefaultJsonProtocol, and RootJsonFormat reference with Akka HTTP examples
  • akka-http-circe IntegrationFailFastCirceSupport and ErrorAccumulatingCirceSupport for Akka HTTP Circe marshalling