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 |
|---|---|
|
The JSON layer: a fast parser and writer, plus the |
|
The serialization layer: |
|
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.
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 |
|
Scala CLI, uPickle only |
|
sbt |
|
Mill |
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
|
|
|
|
|
|
|
the value of |
|
array |
|
an object when the keys are written as strings or numbers ( |
tuple |
array of its elements |
|
|
|
a number, but a string |
|
strings: |
|
|
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:
-
Kmis an opaque type (see Opaque Types & Value Classes). It has noMirror, so it needs a hand-written instance; here it reusesDouble, andLeg, which contains it, derives normally. -
LocalDateis a Java class that uPickle knows nothing about. Defininggiven ReadWriter[LocalDate]once at top level makes every case class that contains a date derive, and theDateTimeParseExceptionof a bad value appears as the cause of the usual path-bearingTraceException. -
Levelis an enum written as a lower-case string instead of the default case name, which also fixes the spelling a client sees. Putting thegivenin the companion object makes it the one found forLeveleverywhere. -
Pointis 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)
Source: Scala Toolkit: How to serialize an object to JSON? and Scala Toolkit: How to deserialize JSON to an object?
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,readandreadwriter-based conversions throwTraceExceptionwrapping the real cause; see Parse errors and thedecodehelper above. -
numis aDouble. Use.toIntor.toLongconsciously, and deserialize large integers into aLong,BigIntorBigDecimalfield. -
Missing
deriveson 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
nullas zero are accepted, see Read errors, loud and silent. -
Optional fields and defaults. A default is omitted on write unless
serializeDefaultsis on; a client that expects every key must be served by a custom flavour. -
Mutable trees.
ujson.Valuealiases share state; useujson.copybefore changing a value you also hand to another part of the program. -
Enums as strings. Renaming an
enumcase changes the wire format. Pin it with a customReadWriterwhen the names are a public contract. -
Recursive types. A
case class Tree(label: String, children: List[Tree] = Nil) derives ReadWriterworks (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 latestin the same pages is fine for a script but not for a build you want to reproduce. -
The Mill snippet uses
build.sc,Aggandivy"...", all of which belong to old Mill versions; current Mill builds usebuild.millandmvn"..."coordinates. -
The custom-serialization page prints
[5, "bar"]; the program prints["bar",5]. -
The tutorials name
upickle.core.AbortExceptionas the exception of a bad read. In uPickle 4.4.3 it is the cause of anupickle.core.TraceVisitor.TraceException. -
The Scala 2
implicit val ... = macroRWform is shown next to the Scala 3 form on every page; on Scala 3, preferderives ReadWriter. -
Books and blog posts from the Scala 2 era use
play-json,circeorjson4sfor JSON. They are still maintained and are listed on Ecosystem, but uPickle is what the Toolkit ships.