Skip to content
datahacks.dev
JSON

Reading JSON in Java With Jackson Records and Trees

Read JSON in Java with Jackson 2.20: records, JsonNode trees, unknown fields, duplicate keys, the nesting and number limits, and the date trap on writing.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

Jackson is the library most Java and Kotlin services reach for, and it has two ways in. You can bind JSON to a class, which gives you types and compile-time names. Or you can read it as a tree of nodes, which gives you flexibility and the chance to look before you commit to a shape. Both are in jackson-databind. I used each on one order document and tested the behaviors that tend to surprise people coming from other languages.

Binding to records

Java
record Item(String sku, int qty, double price) {}
record Customer(String name, String email) {}
record Order(long id, Customer customer, List<Item> items,
             String status, JsonNode extra, Instant created_at) {}

ObjectMapper m = JsonMapper.builder().addModule(new JavaTimeModule()).build();
Order o = m.readValue(json, Order.class);

It read: id was 9007199254740993 as a long, the customer's email was null, there were two items, extra held the object as a JsonNode printed as {"gift":true,"wrap":"blue"}, and created_at was an Instant of 2026-01-02T03:04:05Z. Records work with Jackson 2.12 and later without annotations, and the component names are the keys. A JsonNode field is the escape hatch for the part of the document you do not want to model, which is the right place for a free-form extra (handling unknown JSON fields in Go uses json.RawMessage for the same job).

The JavaTimeModule is required for Instant and LocalDate. Without it Jackson refuses with a message that tells you to add the module, which is one of the clearer errors in the library.

Unknown fields fail by default

Go ignores members it does not know. Jackson does the opposite:

Java
m.readValue("{\"status\":\"paid\",\"surprise\":1}", Slim.class);
// UnrecognizedPropertyException: Unrecognized field "surprise" (class Main$Slim),
// not marked as ignorable (one known property: "status"])

FAIL_ON_UNKNOWN_PROPERTIES is on by default, and the message even lists the properties it knows, which makes a typo easy to find. For a consumer that must tolerate additions, turn it off on the mapper or mark the class with @JsonIgnoreProperties(ignoreUnknown = true):

Java
ObjectMapper lenient = JsonMapper.builder()
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
lenient.readValue("{\"status\":\"paid\",\"surprise\":1}", Slim.class);   // Slim[status=paid]

Pick one on purpose. A strict mapper on a request body catches client typos. A strict mapper on an event consumer breaks every time a producer adds a field (event payloads in JSON and schema evolution).

Key matching is case sensitive by default. {"STATUS":"x"} gave Slim[status=null] with the lenient mapper, and Slim[status=x] after enabling MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES. That is the opposite of Go, where the key matched without a switch (parsing JSON in Go with struct tags). Leave it off unless you have clients you cannot fix.

The tree: null, missing and numbers

When the shape is not known, readTree returns a JsonNode:

Java
JsonNode tree = m.readTree(json);
tree.get("id");                                 // 9007199254740993
tree.get("id").isLong();                        // true
tree.get("nope");                               // null  (a Java null)
tree.path("nope").isMissingNode();              // true
tree.get("customer").get("email").isNull();     // true

The tree separates the three cases: a member that is not there (get returns Java null, path returns a MissingNode), a member that is JSON null (a NullNode with isNull() true), and a value. get followed by a method call is how most NullPointerExceptions in Jackson code start. Use path, which never returns Java null, and check isMissingNode() and isNull() explicitly.

Numbers keep their digits as long as you stay in the tree: the 16 digit id printed exactly. For decimals, the default reads a number with a fraction as a double, so 0.1000000000000000055511151231257827 printed as 0.1. With DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS enabled, the tree printed 0.1000000000000000055511151231257827. Turn that on for money and anything where trailing digits are data.

Duplicate keys and limits

Java
m.readTree("{\"role\":\"user\",\"role\":\"admin\"}");
// {"role":"admin"}

The last one won, as in most parsers, with no warning. A mapper built with StreamReadFeature.STRICT_DUPLICATE_DETECTION enabled threw Duplicate field 'role' for the same text. If the body decides who the caller is, turn it on (JSON security mistakes in apps and pipelines shows how ten parsers disagreed).

Since version 2.15 Jackson also enforces limits that it reads from StreamReadConstraints, and the defaults protect you from the cases that hurt:

Text
depth 1500          Document nesting depth (1001) exceeds the maximum allowed (1000)
depth 900           parsed
1,500 digit number  Number value length (1500) exceeds the maximum allowed (1000)

A depth of 1,000 and a number length of 1,000 are the defaults, and the library throws an exception that names the setting. There are also limits on string length and document size, which you can raise for trusted input and lower for untrusted (limiting depth and size of incoming JSON discusses where to set them). These are much better defaults than most languages offer, and they answer the deep nesting problem for the parser, although code that walks the tree afterwards can still recurse (denial of service through deeply nested JSON).

Strict JSON errors are clear: NaN threw Non-standard token 'NaN': enable JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS to allow, and a trailing comma threw was expecting double-quote to start field name. A string given for a long threw Cannot deserialize value of type long from String "abc".

One default surprised me. After reading {"status":"a"} {} into a record, Jackson returned the first object and said nothing about the second. FAIL_ON_TRAILING_TOKENS is off by default, so a document with extra content after the value is accepted. Enable it for request bodies.

Writing: the timestamp trap

Java
m.writeValueAsString(new Order(5, new Customer("A", null), List.of(...), "paid", null,
    Instant.parse("2026-01-02T03:04:05Z")));
// ..."created_at":1767323045.000000000}

An Instant went out as a number, in seconds with nine decimals, not as an ISO string. SerializationFeature.WRITE_DATES_AS_TIMESTAMPS is on by default. A client expecting "2026-01-02T03:04:05Z" gets a float, and a JavaScript client may round it. Disable the feature on the mapper to get ISO 8601:

Java
JsonMapper.builder().addModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build();

Decide this once in the place where you build the mapper, share one mapper across the application (it is thread safe once configured), and put the configuration under a test that serializes a sample.

Class generators can write the records for you: the JSON to Java converter takes a sample document. The reference is the Jackson databind documentation, and the limits are described in the Jackson 2.15 release notes.

When Jackson is more than the job needs

A small script reading a known file. The JDK has no built-in JSON parser, but a short program that pulls one value from a config can use a lighter library, or a command-line tool such as jq called from the build.

You only need to validate. A schema validator checks a document without binding it to classes (validating JSON with jsonschema in Python shows the idea; Java has equivalents).

Startup time or size matters. Jackson uses reflection, which costs at startup and in native images. Libraries with compile-time code generation exist. I did not measure them here.

You want a different style. Streaming with JsonParser avoids building a tree for large input, and is the right tool when the document is bigger than you want in memory (handling JSON files too large to parse).

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.