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.

Share

Contents

What the tree model is

ObjectMapper.readTree parses a JSON document completely and returns its root as a JsonNode. Every JSON value becomes one node object. A JSON object becomes an ObjectNode, which keeps its fields in a LinkedHashMap, so field order is preserved. A JSON array becomes an ArrayNode backed by an ArrayList. Strings, numbers, booleans and null become small immutable value nodes such as TextNode, IntNode and NullNode. Because the whole document is in memory as objects, the tree costs more memory and time than data binding to classes, and much more than token-by-token streaming, as the last program measures.

JsonNode itself is read-only: it has methods to navigate and read values, but none to change them. Changes go through the two container subclasses, ObjectNode and ArrayNode.

The main tree model operations
OperationMethodsSinceNotes
ParsereadTreeJackson 1Whole document into memory
Navigateget, path, atJackson 1 / 2.3 for atget returns null when absent, path a MissingNode
Strict accessrequired, requiredAt2.10Throw with a clear message when absent
Optional accessoptional, asOptional2.19Return java.util.Optional
Iterateproperties(), valueStream(), propertyStream()2.15, 2.19fields() is deprecated as of 2.19
SearchfindValue, findValues, findParentsJackson 1Match a field name at any depth
Build and editput, set, remove, withObjectJackson 1 / 2.14 for pointer withObjectOnly on ObjectNode and ArrayNode
ConverttreeToValue, valueToTreeJackson 1Between 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, so the program can show how each kind of node, and a missing one, behaves.

Java
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:

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(), asText() and asDouble() read scalar values. Array nodes are Iterable, so a for loop walks the tags.
  • get("phone") returns Java null for a missing field, so chaining get calls throws NullPointerException as soon as one level is absent. path("phone") returns a MissingNode instead, which can be chained safely: the three-level path to a non-existent lat simply produced the default -1.0.
  • at("/address/city") takes a JSON Pointer (RFC 6901), a compact path syntax in which array elements are addressed by index, as in /tags/1.
  • The as... methods convert loosely and never fail: "Ana" as an int is 0. Use isInt(), isTextual() and friends when the type matters.
  • An explicit JSON null is a NullNode, and its asText() is the string "null", not an empty string. Code that copies asText() values into a database therefore stores the word "null". Check isNull() first, or use asText(defaultValue), which returns the default for both null and missing nodes.

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 int, one too big for a long, two decimals and a number written as a string, because each of those lands in a different node class or conversion rule.

Java
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:

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 IntNode, 9,000,000,000 a LongNode, and the 30-digit number a BigIntegerNode: integers are never rounded on parsing.
  • asInt() on the LongNode returned 410065408 without any error: it kept only the low 32 bits, since 9,000,000,000 minus 2 × 232 is 410,065,408. Check canConvertToInt(), which said false, or read the value with asLong().
  • asInt() parses the string "42" into 42, while intValue() returns 0 for any non-numeric node. The as... family converts between types; the ...Value() family only reads numbers.
  • Decimals are DoubleNode by default, so the 34 digits of precise were cut to 0.1. With USE_BIG_DECIMAL_FOR_FLOATS on the mapper, the node is a DecimalNode and keeps every digit. Use that setting when a tree holds money or other exact decimals; see BigDecimal.

Strict and optional access, and JSON Pointer details

get and path hide absent values, which is wrong when a value is mandatory. The program reads a user that lacks an email, has an explicit null nickname, and has link names containing / and ~, the two characters JSON Pointer must escape.

Java
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:

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"
  • required("email") threw IllegalArgumentException with the message "No value for property 'email'". requiredAt even reports the unmatched part of the pointer, /address/city, so the error says exactly which level was missing.
  • optional("email") returned an empty Optional, which orElse turned into "(none)". For the null nickname, optional was present, because the field exists and holds a NullNode. Decide whether your code treats "absent" and "null" the same, and test with isNull() when it does.
  • In a JSON Pointer, / inside a name is written ~1 and ~ is written ~0, so the field self/url is reached with /links/self~1url. A pointer must start with /; "links/self" threw an exception.
  • JsonPointer.compile parses a pointer once for reuse. Its getMatchingProperty() and tail() split it into the first step and the rest, which is useful when walking a pointer by hand.

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.

Java
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:

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 walk builds each leaf's path in JSON Pointer form, such as /orders/0/lines/1/price, which can be passed straight back to at(). 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.
  • fieldNames() and properties() iterate one object's fields in document order. In Jackson 2.19 the older fields() iterator was deprecated in favour of properties().
  • findValue("name") searches depth-first and returns the first match, Ana. findValuesAsText("sku") collected all three SKUs, including the repeated A1, and findParents("email") returned the object that contains the field. These searches match a name at any depth, so a field called name in an unrelated sub-object would match too. Prefer a pointer when you know the path.
  • valueStream() (Jackson 2.19) turns an array's elements into a Stream, so the total of 9.5 + 3.0 + 9.5 = 22.0 is one pipeline.

Building, editing and converting

ObjectNode and ArrayNode are the mutable node types. The program builds an order from scratch, edits a document received from another system without losing fields it does not know about, converts between nodes and records, creates nested configuration with withObject, and shows the shared-node trap.

Java
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:

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
  • put returns the node, so calls chain. putArray and putObject create and attach a child in one step.
  • Editing the tree changed status, removed internalFlag and incremented version 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.
  • treeToValue turns a subtree into a typed object, and valueToTree goes the other way, which is handy for reading the stable parts of a payload into records and keeping the rest as a tree.
  • withObject("/db/pool") created both db and pool on the first call and returned the existing pool on the second, so the two values ended up in the same object.
  • The trap: set("address", address) stores a reference, not a copy. Ana and Ben shared one node, so changing Ben's city to Bergen changed Ana's too. Cho got address.deepCopy(), so when the original was later changed to Tromso, Cho kept Bergen. Copy a node with deepCopy() before attaching it in a second place.
  • JsonNode.equals compares content: key order and whitespace do not matter, which makes it useful in tests. But 1 and 1.0 are different nodes (IntNode and DoubleNode), and array order always matters.

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 readTree, once by binding to a list of records, and once with a raw JsonParser. Each style gets a warm-up run and then the best of 3 runs is shown. This is a rough comparison on a 2-CPU Linux machine with JDK 21; see the JMH page for proper benchmarks.

Java
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:

Output
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 LinkedHashMap for every object, 200,000 maps here, while binding creates one record per event and keeps the values in plain fields.
  • 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

Tree model, data binding and streaming
SituationUse
Structure is fixed and you use most fieldsData binding to records or classes
Structure varies or is only partly knownTree model
A few values from a documentTree model with at("/a/b") or required
Patching JSON while keeping unknown fieldsTree model: ObjectNode edits
Mandatory valuesrequired or requiredAt, not get
Optional values with defaultspath(...).asText(default) or optional
Comparing JSON in testsreadTree(a).equals(readTree(b))
Documents too big for memoryStreaming with JsonParser

For mapper settings that affect all three styles, see ObjectMapper configuration; for changing how individual types are written, see custom serializers.