Jackson Tree Model: JsonNode, ObjectNode & ArrayNode
Jackson's tree model reads JSON into a tree of JsonNode objects that you navigate by name and index, without writing a class for the data. It suits JSON whose shape varies, JSON you only need a few values from, and documents you must edit without losing unknown fields. This page explains the node types, safe navigation, searching and editing, and measures the tree against data binding and streaming.
Contents
What the tree model is
ObjectMapper.readTreeJsonNodeObjectNodeLinkedHashMapArrayNodeArrayListnullTextNodeIntNodeNullNode
JsonNodeObjectNodeArrayNode
| Operation | Methods | Since | Notes |
|---|---|---|---|
| Parse | readTree | Jackson 1 | Whole document into memory |
| Navigate | getpathat | Jackson 1 / 2.3 for at | getnullpathMissingNode |
| Strict access | requiredrequiredAt | 2.10 | Throw with a clear message when absent |
| Optional access | optionalasOptional | 2.19 | Return java.util.Optional |
| Iterate | properties()valueStream()propertyStream() | 2.15, 2.19 | fields() |
| Search | findValuefindValuesfindParents | Jackson 1 | Match a field name at any depth |
| Build and edit | putsetremovewithObject | Jackson 1 / 2.14 for pointer withObject | Only on ObjectNodeArrayNode |
| Convert | treeToValuevalueToTree | Jackson 1 | Between nodes and typed objects |
The examples use Jackson 2.21 on JDK 21. Each one is a complete program that needs only the Jackson jars.
Reading and navigating a tree
The sample document includes a nested object, an array and an explicit null
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class ReadTree {
public static void main(String[] args) throws Exception {
String json = """
{"id": 42, "name": "Ana", "active": true, "score": 98.5,
"tags": ["admin", "editor"],
"address": {"city": "Oslo", "zip": "0150"},
"manager": null}
""";
JsonNode root = new ObjectMapper().readTree(json);
System.out.println("id: " + root.get("id").asInt() + ", name: "
+ root.get("name").asText() + ", score: " + root.get("score").asDouble());
System.out.println("node types: id=" + root.get("id").getNodeType() + ", tags="
+ root.get("tags").getNodeType() + ", manager="
+ root.get("manager").getNodeType());
for (JsonNode tag : root.get("tags")) {
System.out.println("tag: " + tag.asText());
}
// get() returns null for a missing field; path() returns a MissingNode instead
System.out.println("get(\"phone\"): " + root.get("phone"));
System.out.println("path(\"phone\"): isMissingNode=" + root.path("phone").isMissingNode()
+ ", asText=\"" + root.path("phone").asText() + "\"");
System.out.println("deep path, safe: "
+ root.path("address").path("geo").path("lat").asDouble(-1));
// JSON Pointer for nested access in one expression
System.out.println("at(\"/address/city\"): " + root.at("/address/city").asText());
System.out.println("at(\"/tags/1\"): " + root.at("/tags/1").asText());
// asInt converts loosely; check the type when it matters
System.out.println("name.asInt(): " + root.get("name").asInt()
+ ", name.isInt(): " + root.get("name").isInt());
System.out.println("null node: isNull=" + root.get("manager").isNull()
+ ", asText=\"" + root.get("manager").asText() + "\"");
}
}
Output:
id: 42, name: Ana, score: 98.5
node types: id=NUMBER, tags=ARRAY, manager=NULL
tag: admin
tag: editor
get("phone"): null
path("phone"): isMissingNode=true, asText=""
deep path, safe: -1.0
at("/address/city"): Oslo
at("/tags/1"): editor
name.asInt(): 0, name.isInt(): false
null node: isNull=true, asText="null"
,asInt() andasText() read scalar values. Array nodes areasDouble() , so a for loop walks the tags.Iterable returns Javaget("phone") for a missing field, so chainingnull calls throwsget as soon as one level is absent.NullPointerException returns apath("phone") instead, which can be chained safely: the three-level path to a non-existentMissingNode simply produced the default -1.0.lat takes a JSON Pointer (RFC 6901), a compact path syntax in which array elements are addressed by index, as inat("/address/city") ./tags/1- The
methods convert loosely and never fail: "Ana" as an int is 0. Useas... ,isInt() and friends when the type matters.isTextual() - An explicit JSON
is anull , and itsNullNode is the stringasText() , not an empty string. Code that copies"null" values into a database therefore stores the word "null". CheckasText() first, or useisNull() , which returns the default for both null and missing nodes.asText(defaultValue)
Number nodes and conversions
A JSON number has no fixed size, but Java types do, so Jackson picks a node class per value. The document holds an int, a number too big for an intlong
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
public class NumberNodes {
public static void main(String[] args) throws Exception {
String json = """
{"small": 7, "big": 9000000000, "huge": 123456789012345678901234567890,
"price": 19.99, "precise": 0.1000000000000000055511151231257827,
"text": "42", "flag": true}""";
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
// Each JSON value becomes the smallest node class that holds it
root.properties().forEach(e -> System.out.printf("%-8s %-15s %-10s %s%n", e.getKey(),
e.getValue().getClass().getSimpleName(), e.getValue().getNodeType(),
e.getValue().numberType()));
// as...() converts loosely; ...Value() is strict about the node type
JsonNode big = root.get("big");
System.out.println("big.asInt() = " + big.asInt() + " (does not fit in an int)");
System.out.println("big.asLong() = " + big.asLong() + ", canConvertToInt="
+ big.canConvertToInt());
System.out.println("text.asInt() = " + root.get("text").asInt() + ", text.intValue() = "
+ root.get("text").intValue());
System.out.println("flag.asText() = " + root.get("flag").asText());
// Floating point precision depends on a mapper setting
System.out.println("precise as double: " + root.get("precise").doubleValue());
ObjectMapper exact = JsonMapper.builder()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).build();
JsonNode precise = exact.readTree(json).get("precise");
System.out.println("precise as BigDecimal: " + precise.decimalValue() + " ("
+ precise.getClass().getSimpleName() + ")");
}
}
Output:
small IntNode NUMBER INT
big LongNode NUMBER LONG
huge BigIntegerNode NUMBER BIG_INTEGER
price DoubleNode NUMBER DOUBLE
precise DoubleNode NUMBER DOUBLE
text TextNode STRING null
flag BooleanNode BOOLEAN null
big.asInt() = 410065408 (does not fit in an int)
big.asLong() = 9000000000, canConvertToInt=false
text.asInt() = 42, text.intValue() = 0
flag.asText() = true
precise as double: 0.1
precise as BigDecimal: 0.1000000000000000055511151231257827 (DecimalNode)
- 7 became an
, 9,000,000,000 aIntNode , and the 30-digit number aLongNode : integers are never rounded on parsing.BigIntegerNode on theasInt() returned 410065408 without any error: it kept only the low 32 bits, since 9,000,000,000 minus 2 × 232 is 410,065,408. CheckLongNode , which said false, or read the value withcanConvertToInt() .asLong() parses the stringasInt() into 42, while"42" returns 0 for any non-numeric node. TheintValue() family converts between types; theas... family only reads numbers....Value()- Decimals are
by default, so the 34 digits ofDoubleNode were cut to 0.1. Withprecise on the mapper, the node is aUSE_BIG_DECIMAL_FOR_FLOATS and keeps every digit. Use that setting when a tree holds money or other exact decimals; seeDecimalNodeBigDecimal .
Strict and optional access, and JSON Pointer details
getpathnull/~
import com.fasterxml.jackson.core.JsonPointer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Optional;
public class StrictAccess {
public static void main(String[] args) throws Exception {
JsonNode root = new ObjectMapper().readTree("""
{"user": {"id": 42, "name": "Ana", "nickname": null},
"links": {"self/url": "https://example.com/u/42", "a~b": 1}}""");
// required(): fail fast with a clear message instead of a NullPointerException
System.out.println("required id: " + root.required("user").required("id").asInt());
try {
root.required("user").required("email");
} catch (IllegalArgumentException e) {
System.out.println("required email: " + e.getMessage());
}
try {
root.requiredAt("/user/address/city");
} catch (IllegalArgumentException e) {
System.out.println("requiredAt: " + e.getMessage());
}
// optional() and asOptional() (Jackson 2.19+) fit Optional-based code
Optional<String> email = root.get("user").optional("email").map(JsonNode::asText);
System.out.println("optional email: " + email.orElse("(none)"));
// A JSON null is present, so it is neither missing nor absent from optional()
JsonNode nick = root.get("user").get("nickname");
System.out.println("nickname: isNull=" + nick.isNull() + ", asText=\"" + nick.asText()
+ "\", asText(\"-\")=\"" + nick.asText("-") + "\", optional present="
+ root.get("user").optional("nickname").isPresent());
// JSON Pointer escapes: "/" in a name is ~1, "~" is ~0
System.out.println("at(\"/links/self~1url\"): " + root.at("/links/self~1url").asText());
System.out.println("at(\"/links/a~0b\"): " + root.at("/links/a~0b").asInt());
JsonPointer p = JsonPointer.compile("/user/name");
System.out.println("compiled pointer " + p + " -> " + root.at(p).asText()
+ ", head=" + p.getMatchingProperty() + ", tail=" + p.tail());
try {
root.at("links/self");
} catch (IllegalArgumentException e) {
System.out.println("pointer without leading slash: " + e.getMessage());
}
}
}
Output:
required id: 42
required email: No value for property 'email' of `ObjectNode`
requiredAt: No node at '/user/address/city' (unmatched part: '/address/city')
optional email: (none)
nickname: isNull=true, asText="null", asText("-")="-", optional present=true
at("/links/self~1url"): https://example.com/u/42
at("/links/a~0b"): 1
compiled pointer /user/name -> Ana, head=user, tail=/name
pointer without leading slash: Invalid input: JSON Pointer expression must start with '/': "links/self"
threwrequired("email") with the message "No value for property 'email'".IllegalArgumentException even reports the unmatched part of the pointer,requiredAt , so the error says exactly which level was missing./address/city returned an emptyoptional("email") , whichOptional turned into "(none)". For theorElse nickname,null was present, because the field exists and holds aoptional . Decide whether your code treats "absent" and "null" the same, and test withNullNode when it does.isNull()- In a JSON Pointer,
inside a name is written/ and~1 is written~ , so the field~0 is reached withself/url . A pointer must start with/links/self~1url ;/ threw an exception."links/self" parses a pointer once for reuse. ItsJsonPointer.compile andgetMatchingProperty() split it into the first step and the rest, which is useful when walking a pointer by hand.tail()
Walking and searching a tree
Code that validates, redacts or flattens JSON often needs to visit every node. The program uses a store document with two orders, nested customers and line items, because nesting is where walking and searching differ.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import java.util.EnumMap;
import java.util.Map;
public class WalkAndSearch {
// Recursive walk: count every node by type and print the leaf paths
static void walk(JsonNode node, String path, Map<JsonNodeType, Integer> counts) {
counts.merge(node.getNodeType(), 1, Integer::sum);
if (node.isObject()) {
node.properties().forEach(e -> walk(e.getValue(), path + "/" + e.getKey(), counts));
} else if (node.isArray()) {
for (int i = 0; i < node.size(); i++) {
walk(node.get(i), path + "/" + i, counts);
}
} else {
System.out.println(" " + path + " = " + node);
}
}
public static void main(String[] args) throws Exception {
String json = """
{"store": "north",
"orders": [
{"id": 1, "customer": {"name": "Ana", "email": "ana@x.io"},
"lines": [{"sku": "A1", "price": 9.5}, {"sku": "B2", "price": 3.0}]},
{"id": 2, "customer": {"name": "Ben"},
"lines": [{"sku": "A1", "price": 9.5}]}
]}""";
JsonNode root = new ObjectMapper().readTree(json);
System.out.println("leaf values (path = value):");
Map<JsonNodeType, Integer> counts = new EnumMap<>(JsonNodeType.class);
walk(root, "", counts);
System.out.println("node counts: " + counts);
// Iterating one object's fields
JsonNode first = root.get("orders").get(0);
System.out.print("fields of order 1:");
first.fieldNames().forEachRemaining(n -> System.out.print(" " + n));
System.out.println();
// Searching the whole tree by field name, at any depth
System.out.println("findValue(\"name\"): " + root.findValue("name"));
System.out.println("findValuesAsText(\"sku\"): " + root.findValuesAsText("sku"));
System.out.println("findParents(\"email\"): " + root.findParents("email"));
// Streams over values (Jackson 2.19+)
double total = root.get("orders").valueStream()
.flatMap(o -> o.get("lines").valueStream())
.mapToDouble(l -> l.get("price").doubleValue()).sum();
System.out.println("total of all lines: " + total);
}
}
Output:
leaf values (path = value):
/store = "north"
/orders/0/id = 1
/orders/0/customer/name = "Ana"
/orders/0/customer/email = "ana@x.io"
/orders/0/lines/0/sku = "A1"
/orders/0/lines/0/price = 9.5
/orders/0/lines/1/sku = "B2"
/orders/0/lines/1/price = 3.0
/orders/1/id = 2
/orders/1/customer/name = "Ben"
/orders/1/lines/0/sku = "A1"
/orders/1/lines/0/price = 9.5
node counts: {ARRAY=3, NUMBER=5, OBJECT=8, STRING=7}
fields of order 1: id customer lines
findValue("name"): "Ana"
findValuesAsText("sku"): [A1, B2, A1]
findParents("email"): [{"name":"Ana","email":"ana@x.io"}]
total of all lines: 22.0
- The recursive
builds each leaf's path in JSON Pointer form, such aswalk , which can be passed straight back to/orders/0/lines/1/price . It counted 23 nodes: 8 objects, 3 arrays, 7 strings and 5 numbers. A tree of 23 nodes means 23 Java objects plus their maps and lists.at() andfieldNames() iterate one object's fields in document order. In Jackson 2.19 the olderproperties() iterator was deprecated in favour offields() .properties() searches depth-first and returns the first match, Ana.findValue("name") collected all three SKUs, including the repeated A1, andfindValuesAsText("sku") returned the object that contains the field. These searches match a name at any depth, so a field calledfindParents("email") in an unrelated sub-object would match too. Prefer a pointer when you know the path.name (Jackson 2.19) turns an array's elements into avalueStream() , so the total of 9.5 + 3.0 + 9.5 = 22.0 is one pipeline.Stream
Building, editing and converting
ObjectNodeArrayNodewithObject
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
public class BuildAndEdit {
record Item(String sku, int qty) {
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
// Build JSON without classes
ObjectNode order = mapper.createObjectNode();
order.put("id", 7).put("status", "NEW");
ArrayNode items = order.putArray("items");
items.addObject().put("sku", "A1").put("qty", 2);
items.addObject().put("sku", "B2").put("qty", 1);
order.putObject("customer").put("name", "Ana");
System.out.println("built: " + order);
// Edit a document read from elsewhere, keeping fields we do not know about
String fromApi = "{\"id\":7,\"status\":\"NEW\",\"internalFlag\":\"x\",\"version\":3}";
ObjectNode doc = (ObjectNode) mapper.readTree(fromApi);
doc.put("status", "PAID");
doc.remove("internalFlag");
doc.put("version", doc.get("version").asInt() + 1);
System.out.println("edited: " + doc);
// Convert between tree and typed objects
List<Item> typed = List.of(mapper.treeToValue(items.get(0), Item.class),
mapper.treeToValue(items.get(1), Item.class));
System.out.println("typed: " + typed);
JsonNode back = mapper.valueToTree(new Item("C3", 5));
System.out.println("to tree: " + back + ", qty is " + back.get("qty").getNodeType());
// withObject creates missing parents along a JSON Pointer (Jackson 2.14+)
ObjectNode config = mapper.createObjectNode();
config.withObject("/db/pool").put("size", 10);
config.withObject("/db/pool").put("timeoutMs", 500);
System.out.println("withObject: " + config);
// Trap: a node added in two places is the same object, not a copy
ObjectNode address = mapper.createObjectNode().put("city", "Oslo");
ObjectNode people = mapper.createObjectNode();
people.putObject("ana").set("address", address);
people.putObject("ben").set("address", address);
people.get("ben").withObject("address").put("city", "Bergen");
System.out.println("shared: " + people);
people.putObject("cho").set("address", address.deepCopy());
address.put("city", "Tromso");
System.out.println("deepCopy: cho=" + people.at("/cho/address/city").asText()
+ ", ana=" + people.at("/ana/address/city").asText());
// Trees compare by content, so they work for "is this JSON the same?" checks
JsonNode a = mapper.readTree("{\"x\":1,\"y\":[1,2]}");
JsonNode b = mapper.readTree("{\"y\":[1,2], \"x\":1}");
JsonNode c = mapper.readTree("{\"x\":1.0,\"y\":[1,2]}");
System.out.println("same content, different key order and spacing: " + a.equals(b));
System.out.println("1 versus 1.0: " + a.equals(c) + "; array order matters: "
+ mapper.readTree("[1,2]").equals(mapper.readTree("[2,1]")));
}
}
Output:
built: {"id":7,"status":"NEW","items":[{"sku":"A1","qty":2},{"sku":"B2","qty":1}],"customer":{"name":"Ana"}}
edited: {"id":7,"status":"PAID","version":4}
typed: [Item[sku=A1, qty=2], Item[sku=B2, qty=1]]
to tree: {"sku":"C3","qty":5}, qty is NUMBER
withObject: {"db":{"pool":{"size":10,"timeoutMs":500}}}
shared: {"ana":{"address":{"city":"Bergen"}},"ben":{"address":{"city":"Bergen"}}}
deepCopy: cho=Bergen, ana=Tromso
same content, different key order and spacing: true
1 versus 1.0: false; array order matters: false
returns the node, so calls chain.put andputArray create and attach a child in one step.putObject- Editing the tree changed
, removedstatus and incrementedinternalFlag from 3 to 4, while every other field passed through untouched. Mapping the same JSON to a Java class and back would drop any field the class does not declare, a common cause of data loss in services that update JSON documents.version turns a subtree into a typed object, andtreeToValue goes the other way, which is handy for reading the stable parts of a payload into records and keeping the rest as a tree.valueToTree created bothwithObject("/db/pool") anddb on the first call and returned the existingpool on the second, so the two values ended up in the same object.pool- The trap:
stores a reference, not a copy. Ana and Ben shared one node, so changing Ben's city to Bergen changed Ana's too. Cho gotset("address", address) , so when the original was later changed to Tromso, Cho kept Bergen. Copy a node withaddress.deepCopy() before attaching it in a second place.deepCopy() compares content: key order and whitespace do not matter, which makes it useful in tests. ButJsonNode.equals and1 are different nodes (1.0 andIntNode ), and array order always matters.DoubleNode
Tree model, data binding or streaming: a measurement
The three Jackson styles answer the same question at different costs. The program builds a 15.7-million-character array of 200,000 events and sums the amount of every "sale" event, once with readTreeJsonParser
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
public class TreeSpeed {
record Event(long id, String type, String user, double amount, List<String> tags) {
}
interface Task {
double run() throws Exception;
}
static double result;
static long bestOf3(Task task) throws Exception {
task.run(); // warm-up run, not counted
long best = Long.MAX_VALUE;
for (int run = 0; run < 3; run++) {
long t0 = System.nanoTime();
result = task.run();
best = Math.min(best, System.nanoTime() - t0);
}
return best / 1_000_000;
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
ArrayNode events = mapper.createArrayNode();
for (int i = 0; i < 200_000; i++) {
ObjectNode e = events.addObject().put("id", i)
.put("type", i % 3 == 0 ? "sale" : "view")
.put("user", "user" + (i % 1000)).put("amount", i % 100 / 4.0);
e.putArray("tags").add("t" + (i % 7)).add("web");
}
String json = mapper.writeValueAsString(events);
System.out.printf("document: %,d events, %,d characters%n", events.size(), json.length());
// Same question three ways: total amount of all "sale" events
ObjectReader listReader = mapper.readerForListOf(Event.class);
long tree = bestOf3(() -> {
double sum = 0;
for (JsonNode e : mapper.readTree(json)) {
if (e.get("type").asText().equals("sale")) {
sum += e.get("amount").doubleValue();
}
}
return sum;
});
double treeTotal = result;
long binding = bestOf3(() -> {
List<Event> list = listReader.readValue(json);
return list.stream().filter(e -> e.type().equals("sale"))
.mapToDouble(Event::amount).sum();
});
double bindingTotal = result;
JsonFactory factory = mapper.getFactory();
long streaming = bestOf3(() -> {
double sum = 0;
boolean sale = false;
try (JsonParser p = factory.createParser(json)) {
JsonToken t;
while ((t = p.nextToken()) != null) {
if (t == JsonToken.FIELD_NAME && p.currentName().equals("type")) {
p.nextToken();
sale = p.getText().equals("sale");
} else if (t == JsonToken.FIELD_NAME && p.currentName().equals("amount")) {
p.nextToken();
if (sale) {
sum += p.getDoubleValue();
}
}
}
}
return sum;
});
System.out.println("total of sales: tree " + treeTotal + ", binding " + bindingTotal
+ ", streaming " + result);
System.out.printf("tree model (readTree): %4d ms%n", tree);
System.out.printf("data binding (records): %4d ms%n", binding);
System.out.printf("streaming (JsonParser): %4d ms%n", streaming);
}
}
Output of the three-style comparison:
document: 200,000 events, 15,686,891 characters
total of sales: tree 825008.25, binding 825008.25, streaming 825008.25
tree model (readTree): 693 ms
data binding (records): 274 ms
streaming (JsonParser): 101 ms
- All three produced the same total, 825008.25.
- The tree took roughly two to three times as long as data binding. It creates a node object for every value plus a
for every object, 200,000 maps here, while binding creates one record per event and keeps the values in plain fields.LinkedHashMap - Streaming was the fastest by a wide margin and keeps almost nothing in memory, because it looks at one token at a time. It is also the most code and the easiest to get wrong; it pays off for documents too large to hold in memory.
- For typical API payloads of a few kilobytes, all three take microseconds, so choose by convenience. The differences matter for large documents and high request rates.
Which one to use
| Situation | Use |
|---|---|
| Structure is fixed and you use most fields | Data binding to records or classes |
| Structure varies or is only partly known | Tree model |
| A few values from a document | Tree model with at("/a/b")required |
| Patching JSON while keeping unknown fields | Tree model: ObjectNode |
| Mandatory values | requiredrequiredAtget |
| Optional values with defaults | path(...).asText(default)optional |
| Comparing JSON in tests | readTree(a).equals(readTree(b)) |
| Documents too big for memory | Streaming with JsonParser |
For mapper settings that affect all three styles, see