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.

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
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:
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):
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:
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(); // trueThe 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
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:
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
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:
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).


