For most applications, ObjectMapper.readValue() and writeValueAsString() are all you need. But when JSON files reach hundreds of megabytes, or when you need to work with dynamic JSON whose schema you do not know at compile time, Jackson offers two lower-level APIs that give you full control: the Streaming API (JsonParser / JsonGenerator) and the Tree Model (JsonNode). This guide explains when to use each and shows practical examples for both.
The Streaming API: Parsing Gigabyte-Sized JSON Files
The Streaming API processes JSON one token at a time. It never loads the entire document into memory, making it the only practical choice for very large files. The trade-off is verbosity — you must walk the token stream yourself.
Reading a Large JSON Array with JsonParser
Suppose you have a file with millions of log entries and you only need to extract records that match a filter condition:
JsonFactory jsonFactory = new JsonFactory();
int errorCount = 0;
try (JsonParser parser = jsonFactory.createParser(new File("large-logs.json"))) {
// Confirm the root is an array
if (parser.nextToken() != JsonToken.START_ARRAY) {
throw new IllegalStateException("Expected a JSON array at the root");
}
// Walk each element in the array
while (parser.nextToken() != JsonToken.END_ARRAY) {
String logLevel = null;
String logMessage = null;
// Walk each field inside the current object
while (parser.nextToken() != JsonToken.END_OBJECT) {
String fieldName = parser.getCurrentName();
parser.nextToken(); // Move to field value
if ("level".equals(fieldName)) {
logLevel = parser.getText();
} else if ("message".equals(fieldName)) {
logMessage = parser.getText();
}
// All other fields are skipped automatically
}
if ("ERROR".equals(logLevel)) {
System.out.println("ERROR: " + logMessage);
errorCount++;
}
}
}
System.out.println("Total errors found: " + errorCount);
Sample input (large-logs.json excerpt):
[
{"level":"INFO","message":"Application started"},
{"level":"ERROR","message":"Database connection failed"},
{"level":"INFO","message":"Retrying connection"}
]
Output:
ERROR: Database connection failed
Total errors found: 1
Writing JSON with JsonGenerator
JsonGenerator writes tokens directly to an OutputStream or a Writer without building an in-memory object graph first:
JsonFactory jsonFactory = new JsonFactory();
try (JsonGenerator generator = jsonFactory.createGenerator(
new File("output.json"), JsonEncoding.UTF8)) {
generator.useDefaultPrettyPrinter(); // Optional: pretty print
generator.writeStartArray();
for (int recordIndex = 0; recordIndex < 1_000_000; recordIndex++) {
generator.writeStartObject();
generator.writeNumberField("id", recordIndex);
generator.writeStringField("status", "active");
generator.writeEndObject();
}
generator.writeEndArray();
}
// Writes 1 million records to output.json with constant memory usage
The Tree Model: Working with Dynamic or Unknown JSON
The Tree Model represents JSON as a navigable JsonNode tree. It is ideal when the JSON schema is not known at compile time, when you need to inspect or transform JSON structure without a matching POJO, or when you want to extract one field without deserialising everything.
ObjectMapper mapper = new ObjectMapper();
String json = "{"
+ ""orderId":1001,"
+ ""customer":{"name":"Alice","tier":"gold"},"
+ ""items":[{"sku":"KB-01","qty":2},{"sku":"MS-42","qty":1}]"
+ "}";
// Parse into a tree
JsonNode rootNode = mapper.readTree(json);
// Navigate with path() — never throws NullPointerException even for missing nodes
String customerName = rootNode.path("customer").path("name").asText();
System.out.println("Customer: " + customerName); // Output: Customer: Alice
// Navigate an array
JsonNode itemsArray = rootNode.path("items");
for (JsonNode itemNode : itemsArray) {
String sku = itemNode.path("sku").asText();
int qty = itemNode.path("qty").asInt();
System.out.println(sku + " x" + qty);
}
// Output:
// KB-01 x2
// MS-42 x1
// Check if a node exists
boolean hasDiscount = rootNode.has("discountCode");
System.out.println("Has discount: " + hasDiscount); // Output: Has discount: false
Mixing Tree Model with Data Binding
You can switch between the Tree Model and POJO binding at any node in the tree. This is useful when most of the JSON is dynamic but one known subtree maps to a concrete type:
JsonNode customerNode = rootNode.path("customer");
// Convert this subtree node into a strongly typed POJO
CustomerRecord customer = mapper.treeToValue(customerNode, CustomerRecord.class);
System.out.println(customer.getName()); // Output: Alice
Streaming API vs Tree Model vs Data Binding: When to Use Which
Each of Jackson’s three processing modes has a distinct performance and complexity profile. The right choice depends entirely on whether your schema is known, and how large the input is likely to be.
| Approach | Best for | Memory usage | Code verbosity |
|---|---|---|---|
Data Binding (readValue) | Known schema, standard POJO mapping | Loads full object | Low |
Tree Model (readTree) | Dynamic / unknown schema, quick inspection | Loads full tree | Medium |
Streaming (JsonParser) | Very large files, filtering without full load | Constant (streaming) | High |
See Also
- Part 1: Jackson ObjectMapper Complete Guide
- Part 4: Custom Serialisers and Mix-ins
- Part 5: Polymorphic Deserialisation
- Part 7: Jackson Security and Best Practices
AI Prompts for Streaming API and JsonNode
Stream-Parse a Large JSON File
I have a large JSON file containing an array of objects with this structure: [describe or paste one example object]. Using Jackson 3’s JsonParser streaming API, write a method that reads the file token-by-token, extracts only the objects where [describe your filter condition], and collects them into a list — without loading the entire file into memory. Use try-with-resources for the parser and explain each token type encountered in the loop.
What it does: Produces a JsonParser loop that advances through START_ARRAY, START_OBJECT, FIELD_NAME, and END_OBJECT tokens, applies the specified filter predicate, and collects matching objects — with inline comments naming each token type so the flow is readable even without prior streaming API knowledge.
When to use it: When processing large export files, analytics dumps, or event logs where loading the full document into memory would exhaust the heap. The streaming approach keeps memory usage constant regardless of file size, making it suitable for files in the hundreds of megabytes or larger.
Stream-Write a JSON Array to OutputStream
I have a List of domain objects of this type: [paste class here]. Using Jackson 3’s JsonGenerator, write a method that streams this list to an OutputStream as a JSON array without building the full JSON string in memory first. Use try-with-resources, call writeStartArray and writeEndArray around the loop, and write each object field-by-field. Show how to wire this into a Spring streaming ResponseEntity.
What it does: Creates a try-with-resources JsonGenerator block that emits each object field-by-field using writeStartObject, typed write methods, and writeEndObject, plus a Spring StreamingResponseBody integration example that flushes the response incrementally rather than buffering the full payload.
When to use it: When generating large JSON responses in a streaming REST endpoint, writing ETL output files, or any scenario where backpressure and low memory footprint matter more than code simplicity. Particularly valuable when response size is unpredictable and buffering could cause out-of-memory errors under load.
Navigate Nested JSON with JsonNode
I have this complex nested JSON payload: [paste JSON here]. Using Jackson 3’s JsonNode tree model, show me how to safely navigate to these specific fields: [list the fields]. Use path() instead of get() where a field may be absent. For any array nodes, show iteration with elements(). Where a subtree maps to a known type, show how to convert it with treeToValue(). Explain the difference between path() returning MissingNode and get() returning null.
What it does: Demonstrates safe tree traversal of the provided JSON using path() (returns MissingNode, never null), explains why get() is unsafe for optional fields, shows elements() iteration for array nodes, and demonstrates treeToValue(subtree, MyDto.class) for mixed tree-and-binding workflows where only part of the document maps to a known type.
When to use it: When consuming a heterogeneous or partially-unknown JSON payload — such as a webhook with a variable structure depending on event type — where you need to inspect a few fields dynamically and bind only the known subtrees to typed classes, rather than defining a full POJO for the entire document.
Conclusion
For everyday JSON mapping, stick with data binding — it is concise and safe. Reach for the Tree Model when you need to navigate dynamic or partially-known JSON without creating a POJO for every possible structure. Switch to the Streaming API only when memory is the constraint, such as processing large export files or ingesting event streams. Knowing which tool to apply in each scenario is what separates a good Jackson developer from a great one.