Introduction to Yojson

2026-09-12 · 6 min

§ 01

Install and the two variants

Install.

$ opam install yojson

dune, for a library that reads and writes JSON.

(library
(name myapp)
(libraries yojson))

Yojson ships two JSON ASTs. Basic.t is strict JSON; Safe.t adds variants for values JSON has no native representation for - large ints, tuples, variants - which round-trip through a convention rather than the spec.

type Basic.t =
[ `Null
| `Bool of bool
| `Int of int
| `Float of float
| `String of string
| `Assoc of (string * Basic.t) list
| `List of Basic.t list
]
type Safe.t =
[ Basic.t
| `Intlit of string
| `Tuple of Safe.t list
| `Variant of string * Safe.t option
]

Safe.t is the default nearly everyone reaches for - it is a strict superset, and ppx_deriving_yojson (covered below) targets it.

open Yojson.Safe
§ 02

Building a value by hand

Every constructor written out, for a small object.

let config : Yojson.Safe.t =
`Assoc
[ ("host", `String "localhost")
; ("port", `Int 8080)
; ("debug", `Bool false)
; ("tags", `List [ `String "web"; `String "prod" ])
; ("timeout", `Null)
]

Serializing to a string, or pretty-printed.

# Yojson.Safe.to_string config;;
- : string =
"{\"host\":\"localhost\",\"port\":8080,\"debug\":false,\"tags\":[\"web\",\"prod\"],\"timeout\":null}"
# Yojson.Safe.pretty_to_string config;;
- : string =
"{\n \"host\": \"localhost\",\n \"port\": 8080,\n ...\n}"

Writing directly to a file or channel, without building an intermediate string.

let () = Yojson.Safe.to_file "config.json" config
let () = Yojson.Safe.to_channel stdout config
let () = Yojson.Safe.pretty_to_channel stdout config
§ 03

Parsing

from_string raises Yojson.Json_error on malformed input rather than returning an option - wrap it if the input is untrusted.

let v = Yojson.Safe.from_string
{|{"host": "localhost", "port": 8080}|}

A malformed document.

# Yojson.Safe.from_string "{not json";;
Exception: Yojson.Json_error "Line 1, bytes 1-4:\nInvalid token 'not'"

from_file and from_channel, for reading directly off disk or a socket.

let v = Yojson.Safe.from_file "config.json"
let v = Yojson.Safe.from_channel stdin

A safe wrapper returning a result instead of raising, the shape most application code actually wants at a boundary.

let parse_safely (s : string) : (Yojson.Safe.t, string) result =
try Ok (Yojson.Safe.from_string s)
with Yojson.Json_error msg -> Error msg
§ 04

Pattern matching directly on the AST

Yojson.Safe.t is a plain polymorphic variant, so ordinary match handles any shape with no library call at all - this is the lowest-level way to pull a value out.

let get_port (v : Yojson.Safe.t) : int option =
match v with
| `Assoc fields ->
(match List.assoc_opt "port" fields with
| Some (`Int n) -> Some n
| _ -> None)
| _ -> None

A recursive walk - counting every string value anywhere in a document, regardless of nesting depth.

let rec count_strings (v : Yojson.Safe.t) : int =
match v with
| `String _ -> 1
| `List xs -> List.fold_left (fun acc x -> acc + count_strings x) 0 xs
| `Assoc fields ->
List.fold_left (fun acc (_, v) -> acc + count_strings v) 0 fields
| `Tuple xs -> List.fold_left (fun acc x -> acc + count_strings x) 0 xs
| _ -> 0

Rewriting a document in place - a pure recursive transform that doubles every int it finds, leaving everything else untouched.

let rec double_ints (v : Yojson.Safe.t) : Yojson.Safe.t =
match v with
| `Int n -> `Int (n * 2)
| `List xs -> `List (List.map double_ints xs)
| `Assoc fields -> `Assoc (List.map (fun (k, v) -> (k, double_ints v)) fields)
| other -> other
§ 05

Yojson.Safe.Util

member looks up a key on an Assoc, raising Type_error if the value is not an object at all - the common navigation function, and the one everything below chains off.

open Yojson.Safe.Util
let v = Yojson.Safe.from_string
{|{"user": {"name": "ada", "age": 30}}|}
# v |> member "user" |> member "name";;
- : Yojson.Safe.t = `String "ada"

The to_* extractors pull a typed OCaml value out of a Yojson.Safe.t, raising Type_error if the shape does not match.

let name = v |> member "user" |> member "name" |> to_string
let age = v |> member "user" |> member "age" |> to_int
(* name : string = "ada" *)
(* age : int = 30 *)

The full extractor set worth knowing.

to_string to_int to_float to_bool
to_list to_assoc to_number (int or float)
to_option f applies f unless the value is `Null, then None
to_string_option, to_int_option, ... same idea, one per type

A missing key raises Type_error too - member on a key that is not present returns `Null, and to_string on `Null then raises.

# v |> member "missing" |> to_string;;
Exception: Yojson.Safe.Util.Type_error ("Expected string, got null", `Null)

Handling optional fields with to_option, or with the dedicated *_option extractors - the idiomatic way to read a field that may legitimately be absent.

let timeout = v |> member "timeout" |> to_int_option
(* timeout : int option = None, no exception *)

Navigating arrays: index for a specific position, to_list plus List.map for every element.

let v = Yojson.Safe.from_string {|{"tags": ["web", "prod", "api"]}|}
let first_tag = v |> member "tags" |> index 0 |> to_string
let all_tags = v |> member "tags" |> to_list |> List.map to_string

filter_* helpers narrow a list of Yojson.Safe.t to only the ones matching a shape - useful over a heterogeneous list where not every element is expected to be a string.

let mixed = [ `String "a"; `Int 1; `String "b"; `Null ]
# filter_string mixed;;
- : string list = ["a"; "b"]
§ 06

ppx_deriving_yojson

Install, and wire the ppx in through dune.

$ opam install ppx_deriving_yojson

dune.

(executable
(name main)
(libraries yojson)
(preprocess (pps ppx_deriving_yojson)))

One attribute generates both directions, for a plain record - no Util calls to write by hand.

type config = {
host : string;
port : int;
debug : bool;
tags : string list;
} [@@deriving yojson]
(* generates: *)
(* val config_to_yojson : config -> Yojson.Safe.t *)
(* val config_of_yojson : Yojson.Safe.t -> (config, string) result *)

Round-tripping.

let c = { host = "localhost"; port = 8080; debug = false; tags = ["a"; "b"] }
# config_to_yojson c |> Yojson.Safe.to_string;;
- : string = "{\"host\":\"localhost\",\"port\":8080,\"debug\":false,\"tags\":[\"a\",\"b\"]}"
# Yojson.Safe.from_string {|{"host":"x","port":1,"debug":true,"tags":[]}|}
|> config_of_yojson;;
- : (config, string) result = Ok {host = "x"; port = 1; debug = true; tags = []}

Field-level attributes: key renames the JSON field, default supplies a value so absence is not an error, and [@yojson.option] turns a field into an option that maps to/from a missing key rather than JSON null.

type request = {
method_ : string [@key "method"];
path : string;
timeout : int [@default 30];
body : string option [@yojson.option];
} [@@deriving yojson]

Nested records derive independently and compose - a field whose type is itself @@deriving yojson just works, with no extra annotation at the use site.

type user = { name : string; age : int } [@@deriving yojson]
type response = {
status : int;
data : user;
} [@@deriving yojson]

Sum types derive too, tagged by constructor name under a `+kind` key by default.

type shape =
| Circle of float
| Rect of float * float
[@@deriving yojson]
# Circle 5.0 |> shape_to_yojson |> Yojson.Safe.to_string;;
- : string = "[\"Circle\",5.0]"

Deriving on a variant used as a Map/Hashtbl key or elsewhere Ord/Eq/Show are also wanted - stack multiple derivers in one clause, exactly the same pattern the ppx post covers for any deriver.

type shape =
| Circle of float
| Rect of float * float
[@@deriving yojson, eq, show]
§ 07

Error handling at the boundary

config_of_yojson returns Error with a message rather than raising - chain it the same way any other result-returning parser is chained.

let load_config path : (config, string) result =
match Yojson.Safe.from_file path with
| exception Yojson.Json_error msg -> Error ("invalid json: " ^ msg)
| exception Sys_error msg -> Error ("cannot read file: " ^ msg)
| json -> config_of_yojson json

Using it.

# load_config "config.json";;
- : (config, string) result = Ok {host = "localhost"; ...}
# load_config "missing.json";;
- : (config, string) result = Error "cannot read file: missing.json: No such file or directory"
§ 08

Streaming large documents

Both Basic.t and Safe.t build the entire document in memory before returning. For a document too large to hold at once - a multi-gigabyte log export, a paginated dump - Yojson's stream module reads one top-level value at a time.

A file holding one JSON value per line, read lazily rather than parsed as one array.

let process_line (v : Yojson.Safe.t) =
match v with
| `Assoc fields ->
(match List.assoc_opt "level" fields with
| Some (`String "ERROR") -> print_endline "found an error line"
| _ -> ())
| _ -> ()
let () =
let ic = open_in "events.ndjson" in
(try
while true do
let line = input_line ic in
process_line (Yojson.Safe.from_string line)
done
with End_of_file -> ());
close_in ic

For a genuine single large JSON array rather than newline-delimited objects, Yojson.Safe.stream_from_channel yields each top-level value from a sequence of concatenated JSON values without holding the whole input in memory - correct only when the source really is several JSON texts back to back, not one big array.

let () =
let ic = open_in "concatenated.json" in
Yojson.Safe.stream_from_channel ic
|> Stream.iter process_line;
close_in ic
§ 09

Basic vs Safe, and when it matters

A large integer literal - one that does not fit in a native OCaml int - is the case where the two variants actually diverge in practice.

# Yojson.Basic.from_string "99999999999999999999";;
- : Yojson.Basic.t = `Int ... (* silently truncated / platform dependent *)
# Yojson.Safe.from_string "99999999999999999999";;
- : Yojson.Safe.t = `Intlit "99999999999999999999"

Converting between the two ASTs when a library only speaks Basic.t - to_basic drops anything Basic cannot represent, so it is lossy on Intlit/Tuple/Variant.

let safe_v : Yojson.Safe.t = `Intlit "99999999999999999999"
# Yojson.Safe.to_basic safe_v;;
- : Yojson.Basic.t = `String "99999999999999999999"

The reverse direction is always safe, since Basic.t is a strict subset - every constructor already exists in Safe.t.

let basic_v : Yojson.Basic.t = `Int 5
let promoted : Yojson.Safe.t = (basic_v :> Yojson.Safe.t)

Manual traversal is fine for one-off scripts, but the moment a type has more than a couple of fields, generating the (de)serializer with ppx_deriving_yojson beats hand-writing it. For how that kind of deriver actually works under the hood, see ppx in Practice.