JSON with uPickle

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) with the Scala Toolkit 0.9.2, which brings uPickle 4.4.3 and OS-Lib 0.11.8, and every example that shows output was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-16). The typed read and write example was also run on the 3.9 LTS compiler (3.9.0) with the same output, and the Scala 2 form was run on 2.13.18. uPickle is a library, not part of the language, so what matters for the Scala version is only that the artifact is published for it: uPickle 4.4.3 has artifacts for Scala 3, 2.13 and 2.12, and for Scala.js and Scala Native.

JSON is the format almost every Scala program meets first, in configuration files, in HTTP bodies and in data files. uPickle covers it in two layers that you can mix freely: uJson parses text into an in-memory tree you can navigate and change without declaring any types, and uPickle proper turns that text into your own case classes, enums and collections and back, with the conversion code derived by the compiler. The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails: a Trail has a name, a length, an ascent and a Difficulty. The types are those of Case Classes and Enums; the derives clause comes from Type Class Derivation; files are read and written with Files & Processes with OS-Lib. The two other JSON-centred Toolkit pages are HTTP Client with sttp, which sends and receives JSON bodies, and Web Servers with Cask, which serves them.

The layers of uPickle

uPickle is one project that ships several modules, and three names matter:

Name What it is

ujson

The JSON layer: a fast parser and writer, plus the ujson.Value tree (ujson.Obj, ujson.Arr, ujson.Str, ujson.Num, ujson.Bool and ujson.Null). It knows nothing about your types.

upickle.default

The serialization layer: read[T], write[T] and the ReadWriter[T] type class that says how a T maps to JSON. Its Reader[T] and Writer[T] halves can be used alone.

upack

The same ideas for the binary MessagePack format. It is not covered here; the type-class instances you write for JSON work for it unchanged.

The ReadWriter[T] instances do not know about JSON text at all. They describe how a T is visited, and ujson is one back end that turns the visit into JSON text or into a ujson.Value. That is why one derives ReadWriter clause serves every format, and why the same instance converts between a case class and a ujson.Value tree (writeJs) as readily as between a case class and a string.

flowchart LR text["JSON text, file or stream"] -->|"ujson.read"| tree["ujson.Value tree"] tree -->|"ujson.write"| text text -->|"read[T]"| model["case classes, enums, collections"] model -->|"write[T]"| text tree -->|"read[T]"| model model -->|"writeJs"| tree

Compared with the other JVM options, uPickle sits between a dynamic tree API and a reflection-based mapper. Java programs usually reach for Jackson or Gson, which discover the fields of a class at run time by reflection (Java records are covered on Records & Sealed Classes); Kotlin has kotlinx.serialization, which like uPickle uses a compiler-generated serializer; Groovy parses to maps and lists with JsonSlurper, see JSON. uPickle’s derived instances are produced at compile time, so there is no reflection and a missing instance is a compile error, not a run-time surprise.

Getting uPickle

The Scala Toolkit includes uPickle, uJson and OS-Lib in one line. The Toolkit version pins the versions of every library in it; the version used here is 0.9.2.

//> using scala 3.10.0
//> using toolkit 0.9.2

To depend on uPickle alone, name the library. The versions in the next table are the ones the Toolkit 0.9.2 resolves to, and the ones used on this page.

Build tool Declaration

Scala CLI, Toolkit

//> using toolkit 0.9.2 (and //> using test.toolkit 0.9.2 for tests)

Scala CLI, uPickle only

//> using dep com.lihaoyi::upickle:4.4.3

sbt

libraryDependencies += "com.lihaoyi" %% "upickle" % "4.4.3", or the whole Toolkit with libraryDependencies += "org.scala-lang" %% "toolkit" % "0.9.2"

Mill

mvn"com.lihaoyi::upickle:4.4.3" in the module’s dependencies (not run here)

The double colon in the Scala CLI and Mill forms asks for the artifact built for the Scala version in use (upickle_3 here), exactly like %% in sbt. The same artifacts exist for Scala.js and Scala Native, where sbt 1.x writes %%% (in sbt 2 the plain %% adds the platform suffix too); see Scala.js & Scala Native. The build tools are covered on Scala CLI and sbt & Build Tools.

Parsing and navigating with uJson

ujson.read parses text into a ujson.Value. You navigate it by calling the value like a function: json("name") selects a field of an object, json("huts")(1) an element of an array, and the accessor methods str, num, bool, arr and obj say what type you expect to find.

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def parse(): Unit =
  val text = """{"name": "Alpine Loop", "lengthKm": 12.5, "open": true, "huts": ["Refugio", "Pico"], "grade": null}"""
  val json: ujson.Value = ujson.read(text)

  println(json("name").str)
  println(json("lengthKm").num)
  println(json("open").bool)
  println(json("huts")(1).str)
  println(json("grade").isNull)
  println(json("huts").arr)
  println(json.obj.keys.toList)
  println(json("name").getClass)

  try json("name").bool
  catch case e: ujson.Value.InvalidData => println(s"InvalidData: ${e.getMessage}")

  try json("nope")
  catch case e: NoSuchElementException => println(s"NoSuchElementException: ${e.getMessage}")

  try json("huts")(5)
  catch case e: IndexOutOfBoundsException => println(s"IndexOutOfBoundsException: ${e.getMessage}")

  println(json.objOpt.flatMap(_.get("nope")))
  println(json.obj.get("name").map(_.str))

Running it:

Alpine Loop
12.5
true
Pico
true
ArrayBuffer("Refugio", "Pico")
List(name, lengthKm, open, huts, grade)
class ujson.Str
InvalidData: Expected ujson.Bool (data: "Alpine Loop")
NoSuchElementException: key not found: nope
IndexOutOfBoundsException: 5 is out of bounds (min 0, max 1)
None
Some(Alpine Loop)

Everything here is dynamically typed, so a mistake is found at run time. The accessor that does not match the data throws ujson.Value.InvalidData, a missing key throws the ordinary NoSuchElementException of a map, and an index past the end throws IndexOutOfBoundsException. The accessors with an Opt suffix (strOpt, numOpt, boolOpt, arrOpt, objOpt) return an Option instead of throwing when the value has another type, and json.obj.get("key") is the way to look a field up without failing. A JSON null is the value ujson.Null, which isNull tests; it is not a Scala null.

JSON value ujson.Value subclass Accessor and result type

"text"

ujson.Str

str, a String

12.5

ujson.Num

num, a Double

true, false

ujson.Bool (ujson.True, ujson.False)

bool, a Boolean

null

ujson.Null

isNull

[ ... ]

ujson.Arr

arr, a mutable Buffer[ujson.Value]

{ ... }

ujson.Obj

obj, a mutable Map[String, ujson.Value] that keeps insertion order

Two details of num matter for real data. All JSON numbers are stored as a Double, so an integer above 2^53 loses precision on the way in: the text 9007199254740993 is read as 9007199254740992. And num returns a Double even for 12, so you convert with .toInt or .toLong yourself. If those limits matter, deserialize into a Long or a BigInt field (see Numbers) instead of navigating the tree.

The tree can also be matched with the patterns that ujson provides, which is the tidy way to handle a value of unknown shape:

//> using scala 3.10.0
//> using toolkit 0.9.2

def describeValue(v: ujson.Value): String = v match
  case ujson.Str(s)  => s"string $s"
  case ujson.Num(n)  => s"number $n"
  case ujson.Bool(b) => s"boolean $b"
  case ujson.Null    => "null"
  case ujson.Arr(xs) => s"array of ${xs.size}"
  case ujson.Obj(kv) => s"object with keys ${kv.keys.mkString(",")}"

@main def describeAll(): Unit =
  println(ujson.read("""[1,"a",true,null,[],{"k":1}]""").arr.map(describeValue).mkString("; "))
number 1.0; string a; boolean true; null; array of 0; object with keys k

Parse errors

ujson.read accepts only valid JSON: no trailing commas, no comments, no single quotes and no NaN. A document that is not valid fails with a message that names the position. In uPickle 4 the failure is wrapped, which differs from what the Toolkit tutorial shows: ujson.read runs the parser inside a tracing visitor, so what you catch is an upickle.core.TraceVisitor.TraceException whose message is the JSON path of the failure ($ is the document, $['a'] its field a) and whose cause is the real ujson.ParseException.

//> using scala 3.10.0
//> using toolkit 0.9.2

def attempt(label: String)(body: => Any): Unit =
  try println(s"$label -> $body")
  catch
    case e: Exception =>
      val cause = Option(e.getCause).map(c => s"; cause ${c.getClass.getName}: ${c.getMessage}").getOrElse("")
      println(s"$label threw ${e.getClass.getName}: ${e.getMessage}$cause")

@main def errors(): Unit =
  attempt("trailing comma")(ujson.read("""{"a": 1,}"""))
  attempt("truncated")(ujson.read("""{"a": """))
  attempt("trailing text")(ujson.read("[1, 2] x"))
  attempt("single quotes")(ujson.read("""{'a': 1}"""))
  attempt("comments")(ujson.read("""{"a": 1 /* c */}"""))
  attempt("no trace")(ujson.read("{", trace = false))
  attempt("validate")(ujson.validate("""{"a": 1}"""))
  attempt("validate bad")(ujson.validate("""{"a": }"""))
  attempt("duplicate keys")(ujson.read("""{"a": 1, "a": 2}"""))
  attempt("big integer")(ujson.read("[9007199254740993]")(0).num.toLong)
trailing comma threw upickle.core.TraceVisitor$TraceException: $; cause ujson.ParseException: expected json string key got "}" at index 8
truncated threw upickle.core.TraceVisitor$TraceException: $['a']; cause ujson.IncompleteParseException: exhausted input
trailing text threw upickle.core.TraceVisitor$TraceException: $; cause ujson.ParseException: expected whitespace or eof got "x" at index 7
single quotes threw upickle.core.TraceVisitor$TraceException: $; cause ujson.ParseException: expected json value or } got "'" at index 1
comments threw upickle.core.TraceVisitor$TraceException: $; cause ujson.ParseException: expected , or } got "/" at index 8
no trace threw ujson.IncompleteParseException: exhausted input
validate -> ()
validate bad threw ujson.ParseException: expected json value got "}" at index 6
duplicate keys -> {"a":2}
big integer -> 9007199254740992

Three things to notice. A duplicate key is not an error, the last value wins. ujson.validate checks syntax only and, unlike read, throws the bare ParseException. And trace = false turns the wrapper off, which gives the bare exception; use it when you do not need the path. Since the exception you meet depends on the call, a handler for parse failures looks at both:

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.core.TraceVisitor.TraceException

def parse(text: String): Either[String, ujson.Value] =
  try Right(ujson.read(text))
  catch
    case e: TraceException => Left(s"at ${e.getMessage}: ${e.getCause.getMessage}")
    case e: ujson.IncompleteParseException => Left(e.getMessage)

@main def cause(): Unit =
  println(parse("""{"a": [1, 2,]}"""))
  println(parse("""{"a": 1}"""))
  println(parse(""))
  println(classOf[TraceException].getSuperclass)
  println(classOf[ujson.ParseException].getSuperclass)
  println(classOf[upickle.core.AbortException].getSuperclass)
  println(classOf[ujson.Value.InvalidData].getSuperclass)
Left(at $['a'][2]: expected json value got "]" at index 12)
Right({"a":1})
Left(at $: exhausted input)
class java.lang.Exception
class java.lang.Exception
class java.lang.Exception
class java.lang.Exception

The last four lines show why a handler that only names ujson.ParseException misses most failures: TraceException, ParseException, AbortException and InvalidData are all direct subclasses of java.lang.Exception, with no common uPickle parent. When you read user-supplied text, catch Exception (or TraceException plus IncompleteParseException as above) at the boundary and turn it into a value with Either, as in Either & Try.

Source: Scala Toolkit: How to access values inside JSON? (the exception for a mistyped value), uPickle manual

Modifying and constructing JSON

A ujson.Value is mutable. Assigning with json("key") = value replaces or adds a field of an object, arr.append and arr.remove change an array, and obj.remove deletes a field. To build a document from scratch, use ujson.Obj and ujson.Arr with -> pairs; Scala values are converted implicitly where there is a JSON equivalent (strings, numbers, booleans, and other ujson.Value`s). `ujson.write turns the tree back into text.

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def build(): Unit =
  val json = ujson.read("""{"updated": "never", "trails": [{"name": "Alpine Loop", "lengthKm": 12.5}, {"name": "River Walk", "lengthKm": 4}]}""")
  json("updated") = "now"
  json("trails")(0)("lengthKm") = 13.0
  json("trails").arr.append(ujson.Obj("name" -> "Hut Hop", "lengthKm" -> 7.2))
  json("trails").arr.remove(1)
  println(ujson.write(json))
  println(ujson.write(json, indent = 2))

  val obj: ujson.Value = ujson.Obj(
    "library" -> "upickle",
    "versions" -> ujson.Arr("1.6.0", "2.0.0", "3.1.0"),
    "stable" -> true,
    "count" -> 3,
    "none" -> ujson.Null,
  )
  println(ujson.write(obj, sortKeys = true))

  val copy = ujson.copy(obj)
  copy("count") = 4
  val alias = obj
  alias("count") = 99
  println(s"obj=${obj("count")} copy=${copy("count")}")

  println(ujson.Obj.from(Map("a" -> 1, "b" -> 2)))
  println(ujson.Arr.from(List(1, 2, 3)))
  println(ujson.reformat("""{ "a" : [1, 2 ,3] }""", indent = 1))
{"updated":"now","trails":[{"name":"Alpine Loop","lengthKm":13},{"name":"Hut Hop","lengthKm":7.2}]}
{
  "updated": "now",
  "trails": [
    {
      "name": "Alpine Loop",
      "lengthKm": 13
    },
    {
      "name": "Hut Hop",
      "lengthKm": 7.2
    }
  ]
}
{"count":3,"library":"upickle","none":null,"stable":true,"versions":["1.6.0","2.0.0","3.1.0"]}
obj=99 copy=4
{"a":1,"b":2}
[1,2,3]
{
 "a": [
  1,
  2,
  3
 ]
}

A few points follow from the output. ujson.write(json, indent = 2) pretty-prints and sortKeys = true writes the fields alphabetically, which makes output stable for diffs and tests; otherwise the order of insertion is kept. A number whose value is a whole number is written without a fraction (13, not 13.0) because the tree holds a Double. Because values are mutable and shared, val alias = obj is not a copy: the change through alias shows in obj (obj=99) while the ujson.copy is unaffected (copy=4). And ujson.reformat re-indents text without building a tree.

There is no implicit conversion from a Scala Map or List; use ujson.Obj.from and ujson.Arr.from. Passing one to ujson.Value(...) is a compile error:

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def readable(): Unit =
  println(ujson.Value(Map("a" -> 1)))
-- [E007] Type Mismatch Error: Readable.scala:5:25
5 |  println(ujson.Value(Map("a" -> 1)))
  |                      ^^^^^^^^^^^^^
  |                      Found:    Map[String, Int]
  |                      Required: ujson.Readable
  |
  |                      The following import might fix the problem:
  |
  |                        import ujson.Value.JsonableDict
  |
  |
  | longer explanation available when compiling with `-explain`
1 error found
Compilation failed

The ujson.write method also takes escapeUnicode = true, which writes every non-ASCII character as a \uXXXX escape ("é€" becomes "\u00e9\u20ac", and a character outside the Basic Multilingual Plane becomes a surrogate pair, "😀" becoming "\ud83d\ude00"). The default leaves them as readable UTF-8.

Deserialization and serialization: read and write

Parsing checks that the text is JSON. Deserialization goes further and builds your own types, failing unless the required fields exist and have the right types. In uPickle that is upickle.default.read[T], and the opposite direction is write. Both need a ReadWriter[T] (or only a Reader[T] for read and a Writer[T] for write) in implicit scope. Standard types have one already: numbers, strings, booleans, Option, tuples, Either and the collections. For your own case classes and enums, ask for it with a derives clause:

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

enum Difficulty derives ReadWriter:
  case Easy, Moderate, Hard
  case Alpine(grade: Int)

case class Trail(name: String, lengthKm: Double, ascentM: Int, difficulty: Difficulty) derives ReadWriter

@main def typed(): Unit =
  val loop = Trail("Alpine Loop", 12.5, 840, Difficulty.Alpine(3))
  val json: String = write(loop)
  println(json)
  println(read[Trail](json) == loop)

  println(write(Trail("Easy Walk", 3.0, 50, Difficulty.Easy)))
  println(write(loop, indent = 2))

  val primes = read[Map[String, List[Int]]]("""{"primes": [2, 3, 5], "evens": [2, 4, 6]}""")
  println(primes("primes"))
  println(write(Map("Toolkitty" -> 3, "Scaniel" -> 5)))
  println(write((1, "a", true)))
  println(write(Array(1, 2, 3)))
  println(write(Set("a")))
{"name":"Alpine Loop","lengthKm":12.5,"ascentM":840,"difficulty":{"$type":"Alpine","grade":3}}
true
{"name":"Easy Walk","lengthKm":3,"ascentM":50,"difficulty":"Easy"}
{
  "name": "Alpine Loop",
  "lengthKm": 12.5,
  "ascentM": 840,
  "difficulty": {
    "$type": "Alpine",
    "grade": 3
  }
}
List(2, 3, 5)
{"Toolkitty":3,"Scaniel":5}
[1,"a",true]
[1,2,3]
["a"]

derives ReadWriter adds a given ReadWriter[Trail] to the companion object of Trail, built by the compiler from the Mirror that every case class and enum has (see Type Class Derivation). Because Trail mentions Difficulty, that type needs its own instance, and it has one in the example. The Toolkit tutorial shows the same example with a PetOwner class, and the same code compiles on 3.9 LTS: the program above printed identical output when run with //> using scala 3.9.0.

Note what the output teaches about the format. A case class is a JSON object whose keys are the field names, in declaration order. The value 3.0 is written as 3 because JSON has one number type. A Map[String, T] is an object, a List, Vector, Set and Array are arrays, and a tuple is an array of its elements. An enum with no parameters is a bare string; one with parameters is an object with a "$type" tag. The next section collects those rules.

The JSON shapes

The mapping is deterministic, and worth knowing because it is the contract with the other side of the wire. This table summarizes what the examples show.

Scala JSON

case class Trail(name: String, ...)

{"name":"Alpine Loop", ...}, one key per field

enum case without parameters, case object

"Easy", the case name as a string

enum case with parameters, case class in a sealed trait

{"$type":"Alpine","grade":3}, a tagged object with the case name in $type

Option[T]

the value of T, or null for None; as a case class field with a None default the key is omitted

List, Vector, Seq, Set, Array

array

Map[K, V]

an object when the keys are written as strings or numbers (Map(1 -> "a") gives {"1":"a"}), otherwise an array of [key, value] pairs (see below)

tuple

array of its elements

Either[A, B]

[0, a] for Left, [1, b] for Right

Long

a number, but a string "9007199254740993" when it does not fit in a Double

BigInt, BigDecimal, Char, Double.NaN, infinities

strings: "123456789012345678901234567890", "1.50", "x", "NaN", "Infinity"

Unit

null

Numbers

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

@main def numbers(): Unit =
  println(write(List(3.0, 12.5, 1e21, 0.1 + 0.2)))
  println(write(List(1L, 9007199254740993L)))
  println(read[List[Long]]("""[1, "9007199254740993"]"""))
  println(write(BigDecimal("1.50")))
  println(write(BigInt("123456789012345678901234567890")))
  println(write(List(Double.NaN, Double.PositiveInfinity)))
  println(write('x'))
  println(write(()))
  println(write(Map(1 -> "a", 2 -> "b")))
  println(write(Map((1, 2) -> "a")))
  println(write(List[Either[String, Int]](Left("e"), Right(1))))
  println(write("quote\" \n é €"))
[3,12.5,1.0E21,0.30000000000000004]
[1,"9007199254740993"]
List(1, 9007199254740993)
"1.50"
"123456789012345678901234567890"
["NaN","Infinity"]
"x"
null
{"1":"a","2":"b"}
[[[1,2],"a"]]
[[0,"e"],[1,1]]
"quote\" \n é €"

The Long rule keeps data safe: a Long that a JavaScript client could not represent exactly is written as a string, and read accepts either form. BigDecimal and BigInt are always strings, which preserves the digits. Map keys that are not strings (Map(1 -> "a")) become string keys when the key writes as a JSON string or number, and a composite key such as a tuple makes the whole map an array of [key, value] pairs. The line 1.0E21 is the way a Double of that size is printed, valid JSON. Floating-point sums print their binary approximation (0.30000000000000004), as in any language.

Options, defaults, sealed traits and renamed fields

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

case class Hut(name: String, beds: Int = 12, warden: Option[String] = None) derives ReadWriter

sealed trait Segment derives ReadWriter
object Segment:
  case object Path extends Segment
  case class Ridge(exposed: Boolean) extends Segment
  case class River(ford: Boolean) extends Segment

case class Stop(@upickle.implicits.key("place") name: String, minutes: Int) derives ReadWriter

@main def shapes(): Unit =
  println(write(Hut("Refugio")))
  println(write(Hut("Refugio", 20, Some("Ana"))))
  println(read[Hut]("""{"name":"Refugio"}"""))
  println(read[Hut]("""{"name":"Refugio","warden":null}"""))
  println(read[Hut]("""{"name":"Refugio","warden":"Ana"}"""))
  println(write(List(Option(3), None)))

  println(write(List[Segment](Segment.Path, Segment.Ridge(true), Segment.River(false))))
  println(read[List[Segment]]("""["Path",{"$type":"Ridge","exposed":true}]"""))

  println(write(Stop("Refugio", 30)))
  println(read[Stop]("""{"place":"Pico","minutes":5}"""))
{"name":"Refugio"}
{"name":"Refugio","beds":20,"warden":"Ana"}
Hut(Refugio,12,None)
Hut(Refugio,12,None)
Hut(Refugio,12,Some(Ana))
[3,null]
["Path",{"$type":"Ridge","exposed":true},{"$type":"River","ford":false}]
List(Path, Ridge(true))
{"place":"Refugio","minutes":30}
Stop(Pico,5)

Four rules appear in the output. First, default values are not written: Hut("Refugio") writes only the name, and on reading a missing field takes its default. Second, an absent field and a null both become None for an Option field. Third, the tag of a sealed trait or enum is the key "$type" (a case with no parameters is just a string), and @upickle.implicits.key("place") on a field renames the key; the same annotation on a case class of a sealed hierarchy renames its tag (a separate run with @upickle.implicits.key("ridge") on Ridge wrote {"$type":"ridge","exposed":true}). Fourth, only a single Option is flattened to a value or null: a nested Option[Option[Int]] is written as an array ([3], [null] and [] for Some(Some(3)), Some(None) and None), so avoid nesting them in a public format.

The format is designed for a round trip between uPickle programs. It is not the shape you get from other libraries, so code that has to produce a particular JSON document (a public API with another team’s schema, say) needs the annotations above or a custom ReadWriter, described below.

Read errors, loud and silent

A read that cannot succeed throws, and a read that can "succeed" with surprising data does so quietly. The program below shows both.

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

enum Difficulty derives ReadWriter:
  case Easy, Moderate, Hard
  case Alpine(grade: Int)

case class Trail(name: String, lengthKm: Double, ascentM: Int, difficulty: Difficulty) derives ReadWriter

def attempt(label: String)(body: => Any): Unit =
  try println(s"$label -> $body")
  catch
    case e: Exception =>
      val cause = Option(e.getCause).map(c => s"; cause ${c.getClass.getName}: ${c.getMessage}").getOrElse("")
      println(s"$label threw ${e.getClass.getName}: ${e.getMessage}$cause")

@main def readErrors(): Unit =
  println("-- failures")
  attempt("missing field")(read[Trail]("""{"name":"X","lengthKm":1.0,"ascentM":1}"""))
  attempt("wrong type")(read[Trail]("""{"name":"X","lengthKm":"far","ascentM":1,"difficulty":"Easy"}"""))
  attempt("unknown case")(read[Trail]("""{"name":"X","lengthKm":1.0,"ascentM":1,"difficulty":"Extreme"}"""))
  attempt("not a sequence")(read[Map[String, List[Int]]]("""{"name": "Peter"}"""))
  println("-- silent successes")
  attempt("extra field")(read[Trail]("""{"name":"X","lengthKm":1.0,"ascentM":1,"difficulty":"Easy","extra":true}"""))
  attempt("1.5 as Int")(read[Trail]("""{"name":"X","lengthKm":1.0,"ascentM":1.5,"difficulty":"Easy"}"""))
  attempt("null as Int")(read[Trail]("""{"name":"X","lengthKm":1.0,"ascentM":null,"difficulty":"Easy"}"""))
-- failures
missing field threw upickle.core.TraceVisitor$TraceException: $; cause upickle.core.AbortException: missing keys in dictionary: difficulty at index 38
wrong type threw upickle.core.TraceVisitor$TraceException: $['lengthKm']; cause java.lang.NumberFormatException: For input string: "far"
unknown case threw upickle.core.TraceVisitor$TraceException: $['difficulty']; cause upickle.core.AbortException: invalid tag for tagged object: Extreme at index 52
not a sequence threw upickle.core.TraceVisitor$TraceException: $['name']; cause upickle.core.AbortException: expected sequence got string at index 9
-- silent successes
extra field -> Trail(X,1.0,1,Easy)
1.5 as Int -> Trail(X,1.0,1,Easy)
null as Int -> Trail(X,1.0,0,Easy)

The failures are TraceException`s again, with the JSON path of the offending value ($) as the message and the specific problem as the cause: an `AbortException for structure (missing keys, an invalid tag, a string where a sequence was expected) and the plain exception of the conversion for a value (NumberFormatException for "far" as a Double). The index in the message is the character offset in the text. The Toolkit tutorial names upickle.core.AbortException as what you catch; in uPickle 4.4.3 that exception is the cause and the TraceException is the one thrown.

The silent successes are the part to remember. Unknown fields are ignored (which is what you want for forward compatibility, and bad for typos in optional keys). A fractional number read as an Int is truncated (1.5 became 1). And a null read as an Int becomes 0, because the reader for Int treats null as the zero of the type. If your data cannot be trusted, read into ujson.Value, check it, and then convert, or declare the field as an Option[Int], which turns null into None.

Reading and writing files

JSON in a file is just text, so the Toolkit combines uJson or uPickle with OS-Lib’s os.read and os.write. The program below creates a trails file in a temporary directory, so it can run anywhere, edits the untyped tree, reads a part of the document as case classes, and writes them back.

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

case class Trail(name: String, lengthKm: Double, ascentM: Int) derives ReadWriter

@main def files(): Unit =
  val dir = os.temp.dir(prefix = "trailhead-")
  val file = dir / "trails.json"
  os.write(file, """{"updated": "never", "trails": [{"name": "Alpine Loop", "lengthKm": 12.5, "ascentM": 840}, {"name": "River Walk", "lengthKm": 4, "ascentM": 30}]}""")

  // untyped: read, modify, write
  val json = ujson.read(os.read(file))
  json("updated") = "now"
  os.write(dir / "trails-updated.json", ujson.write(json, indent = 2))
  println(os.read(dir / "trails-updated.json"))

  // typed: read part of the document as case classes, write them back
  val trails = read[List[Trail]](json("trails"))
  println(trails)
  val longer = trails.filter(_.lengthKm > 5)
  os.write(dir / "long-trails.json", write(longer, indent = 2))
  println(os.read(dir / "long-trails.json"))

  // other sources: a java.nio Path, an InputStream, a byte array
  println(ujson.read(file.toNIO)("updated"))
  println(ujson.read(os.read.inputStream(file))("trails").arr.length)
  println(read[List[Trail]](writeToByteArray(longer)))

  // appending a record, atomically replacing the file
  val tmp = dir / "trails.json.tmp"
  os.write(tmp, write(trails :+ Trail("Hut Hop", 7.2, 410), indent = 2))
  os.move(tmp, file, replaceExisting = true)
  println(read[List[Trail]](os.read(file)).map(_.name))
{
  "updated": "now",
  "trails": [
    {
      "name": "Alpine Loop",
      "lengthKm": 12.5,
      "ascentM": 840
    },
    {
      "name": "River Walk",
      "lengthKm": 4,
      "ascentM": 30
    }
  ]
}
List(Trail(Alpine Loop,12.5,840), Trail(River Walk,4.0,30))
[
  {
    "name": "Alpine Loop",
    "lengthKm": 12.5,
    "ascentM": 840
  }
]
"never"
2
List(Trail(Alpine Loop,12.5,840))
List(Alpine Loop, River Walk, Hut Hop)

read and ujson.read accept anything that is readable: a String, an array of bytes, a java.nio.file.Path, an InputStream, or a ujson.Value; reading from the Path directly (ujson.read(file.toNIO)) avoids loading the file as a string first. The writing side mirrors it with writeToByteArray, writeTo(value, writer) and writeToOutputStream. The last step of the program writes to a temporary file and moves it over the original, so a crash during the write cannot leave a half-written trails.json. Note that read of a ujson.Value (read[List[Trail]](json("trails"))) converts a subtree without printing and parsing it again.

Custom serialization

When the derived shape is not what you need, build the instance yourself. The tool is bimap: take the ReadWriter of a type you already can handle (String, Double, ujson.Value, a tuple) and map it to and from your type with two functions. The first function converts your type to the simple one, the second goes back.

//> using scala 3.10.0
//> using toolkit 0.9.2

import java.time.LocalDate
import upickle.default.*

case class Bar(i: Int, s: String)
object Bar:
  given ReadWriter[Bar] = readwriter[ujson.Value]
    .bimap[Bar](
      x => ujson.Arr(x.s, x.i),
      json => new Bar(json(1).num.toInt, json(0).str)
    )

opaque type Km = Double
object Km:
  def apply(d: Double): Km = d
  given ReadWriter[Km] = readwriter[Double].bimap[Km](d => d, d => d)

case class Leg(from: String, to: String, length: Km) derives ReadWriter

given ReadWriter[LocalDate] =
  readwriter[String].bimap[LocalDate](_.toString, LocalDate.parse(_))

case class Outing(date: LocalDate, leg: Leg) derives ReadWriter

enum Level:
  case Easy, Hard
object Level:
  given ReadWriter[Level] =
    readwriter[String].bimap[Level](_.toString.toLowerCase, s => Level.valueOf(s.capitalize))

case class Point(lat: Double, lon: Double)
object Point:
  given ReadWriter[Point] =
    readwriter[(Double, Double)].bimap[Point](p => (p.lat, p.lon), (lat, lon) => Point(lat, lon))

@main def custom(): Unit =
  println(write(Bar(5, "bar")))
  println(read[Bar]("""["bar",5]"""))

  val outing = Outing(LocalDate.of(2026, 10, 12), Leg("Refugio", "Pico", Km(4.5)))
  println(write(outing))
  println(read[Outing](write(outing)) == outing)

  println(write(List(Level.Easy, Level.Hard)))
  println(read[List[Level]]("""["hard","easy"]"""))

  println(write(Point(42.5, -0.3)))
  try read[Outing]("""{"date":"12/10/2026","leg":{"from":"A","to":"B","length":2}}""")
  catch case e: Exception => println(s"${e.getMessage}; cause ${e.getCause.getClass.getSimpleName}: ${e.getCause.getMessage}")
["bar",5]
Bar(5,bar)
{"date":"2026-10-12","leg":{"from":"Refugio","to":"Pico","length":4.5}}
true
["easy","hard"]
List(Hard, Easy)
[42.5,-0.3]
$['date']; cause DateTimeParseException: Text '12/10/2026' could not be parsed at index 0

The first case is the Toolkit’s own example, for a class that is stored as the array [text, number]. The Toolkit page prints [5, "bar"] as the result, but the mapping function writes the string first, so the real output is ["bar",5] with no space after the commas; the page is wrong in both respects. The other cases show the common uses:

  • Km is an opaque type (see Opaque Types & Value Classes). It has no Mirror, so it needs a hand-written instance; here it reuses Double, and Leg, which contains it, derives normally.

  • LocalDate is a Java class that uPickle knows nothing about. Defining given ReadWriter[LocalDate] once at top level makes every case class that contains a date derive, and the DateTimeParseException of a bad value appears as the cause of the usual path-bearing TraceException.

  • Level is an enum written as a lower-case string instead of the default case name, which also fixes the spelling a client sees. Putting the given in the companion object makes it the one found for Level everywhere.

  • Point is a case class written as the array [lat, lon], using a tuple as the intermediate type.

For one direction only, readwriter has siblings: reader[ujson.Value].map(f) builds a Reader[T] and writer[ujson.Value].comap(f) a Writer[T]. A Reader alone is enough to read and a Writer alone to write, which is useful for a type you only ever receive, such as an incoming message.

Another flavour: your own Api object

upickle.default is an object of type upickle.Api, and the settings of its format are methods you can override in your own object. The two most useful are tagName, the key used for the discriminator of sealed hierarchies and enums, and serializeDefaults, which makes default values appear in the output.

//> using scala 3.10.0
//> using toolkit 0.9.2

// A custom flavour of uPickle: the tag field is "kind" and default values are always written.
object TrailJson extends upickle.AttributeTagged:
  override def tagName = "kind"
  override def serializeDefaults = true

import TrailJson.*

enum Segment derives ReadWriter:
  case Path
  case Ridge(exposed: Boolean = false)
  case Hut(name: String, beds: Int = 12)

case class Page[A](items: List[A], next: Option[Int] = None) derives ReadWriter

@main def api(): Unit =
  println(write(List[Segment](Segment.Path, Segment.Ridge(), Segment.Hut("Refugio"))))
  println(write(Page(List(1, 2))))
  println(read[Page[String]]("""{"items":["a"],"next":2}"""))
["Path",{"kind":"Ridge","exposed":false},{"kind":"Hut","name":"Refugio","beds":12}]
{"items":[1,2],"next":null}
Page(List(a),Some(2))

The instances belong to the object that defines them. import TrailJson.* replaces import upickle.default.*, and derives ReadWriter then refers to TrailJson.ReadWriter. A value written with TrailJson.write cannot be read with upickle.default.read, and mixing the two gives the compile error "No given instance of type upickle.default.Writer[…​]", because the derived instance lives on the other object. Define the flavour once, in one file, and import it everywhere.

Compile-time errors

Because instances are derived at compile time, the typical mistake is caught by the compiler. A case class that has no ReadWriter is rejected as soon as it is written. The message is long, because the compiler explains the derivation it tried; the line that matters is the first.

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

case class Weather(summary: String, windKmh: Int)

@main def noInstance(): Unit =
  println(write(Weather("sunny", 10)))

The compiler output (shortened; the path is the scratch directory and the middle of the message is trimmed):

-- [E172] Type Error: NoInstance.scala:9:37
9 |  println(write(Weather("sunny", 10)))
  |                                     ^
  |No given instance of type upickle.default.Writer[Weather] was found for a context parameter of method write in trait Api.
  |I found:
  |
  |    upickle.default.superTypeWriter[Weather, V](
  |      ... Mirror.Product{ ... MirroredLabel = "Weather"; MirroredElemLabels = ("summary", "windKmh") } ...
  |      scala.reflect.ClassTag.apply[Weather](classOf[Weather]),
  |      /* missing */summon[upickle.default.Writer[V]], ???, ???)
  |
  |But no implicit values were found that match type upickle.default.Writer[V].
  |
  |The following import might make progress towards fixing the problem:
  |
  |  import upickle.default.ReadWriter.join
1 error found

The fix is derives ReadWriter on Weather. A type that is only a field of another one, like Weather inside a Trail that derives, has the same problem and an even noisier message ending in "Cannot reduce `inline if`", which is a consequence, not the cause: look for "No given instance of type …​ Reader[Weather]" at the top.

Putting it together: a trail report

The last program reads a trails file as typed values, builds a summary as an untyped tree, and writes it. The typed side gives checked data and arithmetic with Scala collections (see Collection Operations); the untyped side gives the freedom to assemble a document whose shape does not deserve a class. writeJs converts any value that has a Writer into a ujson.Value that can be embedded. Decoding goes through a small helper that returns an Either, so a broken file is reported with the path of the problem instead of a stack trace.

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.core.TraceVisitor.TraceException
import upickle.default.*

enum Difficulty derives ReadWriter:
  case Easy, Moderate, Hard
  case Alpine(grade: Int)

case class Trail(name: String, lengthKm: Double, ascentM: Int, difficulty: Difficulty) derives ReadWriter

/** Decode text into a value, turning every failure into a Left with the JSON path. */
def decode[A: Reader](text: String): Either[String, A] =
  try Right(read[A](text))
  catch
    case e: TraceException => Left(s"${e.getMessage}: ${e.getCause.getMessage}")
    case e: Exception      => Left(e.getMessage)

@main def planner(): Unit =
  val dir = os.temp.dir(prefix = "trailhead-")
  os.makeDir(dir / "data")
  val data = dir / "data" / "trails.json"
  os.write(data,
    """[
      |  {"name": "Alpine Loop", "lengthKm": 12.5, "ascentM": 840, "difficulty": {"$type": "Alpine", "grade": 3}},
      |  {"name": "River Walk",  "lengthKm": 4,    "ascentM": 30,  "difficulty": "Easy"},
      |  {"name": "Hut Hop",     "lengthKm": 7.2,  "ascentM": 410, "difficulty": "Moderate"}
      |]""".stripMargin)

  decode[List[Trail]](os.read(data)) match
    case Left(error) => println(s"cannot read ${data.last}: $error")
    case Right(trails) =>
      val byDifficulty = trails.groupBy(_.difficulty.productPrefix).view.mapValues(_.map(_.name)).toMap
      val report = ujson.Obj(
        "count" -> trails.size,
        "totalKm" -> trails.map(_.lengthKm).sum,
        "totalAscentM" -> trails.map(_.ascentM).sum,
        "byDifficulty" -> writeJs(byDifficulty),
        "longest" -> writeJs(trails.maxBy(_.lengthKm)),
      )
      os.write(dir / "report.json", ujson.write(report, indent = 2))
      println(os.read(dir / "report.json"))

  println(decode[List[Trail]]("""[{"name": "Broken", "lengthKm": 1.0, "ascentM": 1}]"""))
{
  "count": 3,
  "totalKm": 23.7,
  "totalAscentM": 1280,
  "byDifficulty": {
    "Easy": [
      "River Walk"
    ],
    "Alpine": [
      "Alpine Loop"
    ],
    "Moderate": [
      "Hut Hop"
    ]
  },
  "longest": {
    "name": "Alpine Loop",
    "lengthKm": 12.5,
    "ascentM": 840,
    "difficulty": {
      "$type": "Alpine",
      "grade": 3
    }
  }
}
Left($[0]: missing keys in dictionary: difficulty at index 49)

The decode helper is the form to copy into an application: one place that knows about TraceException, with the rest of the code seeing an ordinary Either[String, A]. The second call at the end, a trail without a difficulty, produces Left($[0]: missing keys in dictionary: difficulty at index 49), which says the problem is in element 0 of the array.

The Scala 2 form

In Scala 2 there is no derives. The instance is written explicitly with the macro macroRW, and made implicit, usually in the companion object. The Toolkit page shows both forms side by side. This program was run on Scala 2.13.18:

//> using scala 2.13.18
//> using dep com.lihaoyi::upickle:4.4.3

import upickle.default._

case class Trail(name: String, lengthKm: Double, ascentM: Int)
object Trail {
  implicit val rw: ReadWriter[Trail] = macroRW
}

object Legacy extends App {
  val json = write(Trail("Alpine Loop", 12.5, 840))
  println(json)
  println(read[Trail](json))
}
{"name":"Alpine Loop","lengthKm":12.5,"ascentM":840}
Trail(Alpine Loop,12.5,840)

macroRW still works in Scala 3 (given ReadWriter[Trail] = macroRW, in the companion object or at top level) and is the way to give a class an instance without putting derives on its definition, for a class you do not own. The output is the same JSON on both versions, so a Scala 2 service and a Scala 3 service can exchange it. A project that cross-builds uses %% in sbt; the Scala 3 module can also depend on the Scala 2.13 artifact with .cross(CrossVersion.for3Use2_13), see Scala 2 & Scala 3 Compatibility, though there is no reason to do so when upickle_3 exists.

Pitfalls

  • Catching the wrong exception. ujson.read, read and readwriter-based conversions throw TraceException wrapping the real cause; see Parse errors and the decode helper above.

  • num is a Double. Use .toInt or .toLong consciously, and deserialize large integers into a Long, BigInt or BigDecimal field.

  • Missing derives on a nested type. Every type reachable from a field needs an instance, and the error appears at the outer class.

  • Silent leniency. Unknown fields, truncated fractions and null as zero are accepted, see Read errors, loud and silent.

  • Optional fields and defaults. A default is omitted on write unless serializeDefaults is on; a client that expects every key must be served by a custom flavour.

  • Mutable trees. ujson.Value aliases share state; use ujson.copy before changing a value you also hand to another part of the program.

  • Enums as strings. Renaming an enum case changes the wire format. Pin it with a custom ReadWriter when the names are a public contract.

  • Recursive types. A case class Tree(label: String, children: List[Tree] = Nil) derives ReadWriter works (it was run and gave {"label":"root","children":[{"label":"leaf","children":[]}]}).

Where the books are outdated

The Scala 3 Book has no chapter on JSON, so the sources are the Toolkit tutorials, and they are the pages that show their age:

  • The tutorials were written for Toolkit 0.7.0 and uPickle 4.1.0 on Scala 3.4.2; today the Toolkit is 0.9.2 with uPickle 4.4.3, and //> using toolkit latest in the same pages is fine for a script but not for a build you want to reproduce.

  • The Mill snippet uses build.sc, Agg and ivy"...", all of which belong to old Mill versions; current Mill builds use build.mill and mvn"..." coordinates.

  • The custom-serialization page prints [5, "bar"]; the program prints ["bar",5].

  • The tutorials name upickle.core.AbortException as the exception of a bad read. In uPickle 4.4.3 it is the cause of an upickle.core.TraceVisitor.TraceException.

  • The Scala 2 implicit val ... = macroRW form is shown next to the Scala 3 form on every page; on Scala 3, prefer derives ReadWriter.

  • Books and blog posts from the Scala 2 era use play-json, circe or json4s for JSON. They are still maintained and are listed on Ecosystem, but uPickle is what the Toolkit ships.