Jackson Tree Model & Streaming API
When the shape is unknown, climb the tree; when the payload is enormous, ride the stream.
Most of the time you map JSON straight onto a Kotlin data class, and Jackson's databind layer does the rest. But two situations break that comfortable model. The first is when the JSON shape is dynamic or only partially known: a webhook whose fields vary by event type, a third-party API that keeps adding properties, or a document store where each record looks a little different. The second is when the payload is simply too large to hold in memory all at once: a multi-gigabyte export, a never-ending log feed, or a response you must transform on the fly. For these, Jackson offers two lower-level tools that sit beneath databind: the tree model and the streaming API.
The tree model parses an entire JSON document into an in-memory graph of `JsonNode` objects. `ObjectMapper.readTree(...)` gives you the root node, and from there you navigate with `path("field")`, index into arrays, and read typed leaves with `asText()`, `asInt()`, `asBoolean()`, and friends. The crucial idiom is `path` versus `get`: `get` returns a real `null` reference when a field is missing, which is easy to dereference by accident, whereas `path` returns a `MissingNode` that safely chains. A `MissingNode` reports `isMissingNode() == true` and yields your supplied default from accessors, so deep, defensive reads stay null-safe without a forest of `?.` operators. This makes the tree model the natural fit for heterogeneous JSON where you cannot commit to a single class ahead of time.
When you need to build or mutate JSON dynamically, reach for the mutable subtypes. `ObjectMapper.createObjectNode()` returns an `ObjectNode` you can `put("key", value)` into, and `createArrayNode()` returns an `ArrayNode` you can `add(...)` to. Because these are mutable, the tree model doubles as a JSON builder and a patch tool: read a document, splice in a computed field, remove a stale one, and write it back out. Kotlin's operator overloading even lets you index a node with `node["field"]`, which reads cleanly in pipelines. Just remember that the whole document lives in the heap, so the tree model is wrong for anything that might not fit comfortably in memory.
That memory ceiling is exactly where the streaming API earns its keep. `JsonParser` is a forward-only, pull-based cursor over a token stream. You call `nextToken()` to advance and inspect `currentToken()` to see whether you are looking at `START_OBJECT`, `FIELD_NAME`, `VALUE_STRING`, `END_ARRAY`, and so on. Nothing is buffered into a tree, so peak memory stays roughly constant no matter how big the file is. The trade-off is that you, not Jackson, track where you are in the structure. The canonical pattern for a huge top-level array is: advance to `START_ARRAY`, then loop while `nextToken() != END_ARRAY`, and for each element either read fields token by token or hand the sub-tree to `readValueAs(MyType::class.java)` so databind handles one manageable record at a time.
`JsonGenerator` is the mirror image for output: a forward-only writer that emits tokens directly to an `OutputStream` or `Writer` without ever materializing the full document. You call `writeStartArray()`, `writeStartObject()`, `writeStringField("name", value)`, `writeEndObject()`, and so forth, flushing as you go. This is the right tool for streaming a large HTTP response: in Spring you can return a `StreamingResponseBody` and drive a `JsonGenerator` over its output stream, producing a multi-megabyte array while holding only one row in memory at a time. Always close the generator (use Kotlin's `use {}`) so the final tokens are flushed and the underlying stream is released.
The three approaches form a clear decision ladder. Reach for databind and data classes first; it is the most type-safe and the least code. Drop to the tree model when the shape is dynamic, partial, or you need to mutate arbitrary JSON. Drop further to the streaming API only when size forces your hand, or when you genuinely need maximum throughput on a hot path. You can also mix them: stream the outer envelope with a `JsonParser`, but call `readValueAs` on each element so individual records still benefit from databind's convenience. This hybrid keeps memory flat while preserving clean, typed access to each item.
A few practical cautions. Streaming code runs on blocking I/O, so when you call it from a coroutine, wrap it in `withContext(Dispatchers.IO)` to keep it off the event loop; Jackson itself is synchronous and pull-based, not suspend-aware. Reuse a single configured `ObjectMapper` (or the Spring-managed bean) rather than constructing one per request, since building one is comparatively expensive and it is thread-safe once configured. Register the Kotlin module (`jacksonObjectMapper()` from `jackson-module-kotlin`) so nullability and default parameters are honored. And prefer `BigDecimal` for monetary values via `asText().toBigDecimal()` rather than `asDouble()`, because floating point silently corrupts currency. With these habits, the tree model and streaming API turn awkward, oversized, or shapeless JSON into something you can handle safely and efficiently.
import com.fasterxml.jackson.databind.JsonNodeimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapperval mapper = jacksonObjectMapper()fun summarize(json: String): String {val root: JsonNode = mapper.readTree(json)// path() chains safely even when fields are absentval name = root.path("user").path("name").asText("unknown")val premium = root.path("user").path("premium").asBoolean(false)// Iterate an array without knowing its size up frontval tags = root.path("tags").mapNotNull { it.asText(null) }// Money as BigDecimal, never asDouble()val total = root.path("order").path("total").asText("0").toBigDecimal()val missing = root.path("nope").isMissingNode // true, no NPEreturn "$name premium=$premium tags=$tags total=$total absent=$missing"}
Tree model: navigate dynamic JSON safely with path() and read typed leaves. path() returns a MissingNode (never null), so deep reads stay null-safe.
import com.fasterxml.jackson.databind.node.ArrayNodeimport com.fasterxml.jackson.databind.node.ObjectNodeimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapperval mapper = jacksonObjectMapper()fun patchOrder(original: String, discount: java.math.BigDecimal): String {val root = mapper.readTree(original) as ObjectNode// splice in a computed fieldval total = root.path("total").asText("0").toBigDecimal()root.put("discounted", (total - discount).toPlainString())// append to a nested array (get-or-create; avoids deprecated withArray)val history = (root.get("history") as? ArrayNode) ?: root.putArray("history")history.add(mapper.createObjectNode().put("event", "discount_applied"))// drop a stale fieldroot.remove("internalNote")return mapper.writeValueAsString(root)}
Build and mutate JSON with ObjectNode/ArrayNode. The tree model doubles as a JSON builder and a patch tool. Get-or-create the array explicitly instead of the ambiguous withArray(String) overload.
import com.fasterxml.jackson.core.JsonTokenimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport java.io.InputStreamdata class Record(val id: Long, val amount: java.math.BigDecimal)val mapper = jacksonObjectMapper()suspend fun sumAmounts(input: InputStream): java.math.BigDecimal =withContext(Dispatchers.IO) {mapper.factory.createParser(input).use { parser ->var sum = java.math.BigDecimal.ZEROcheck(parser.nextToken() == JsonToken.START_ARRAY) { "expected array" }while (parser.nextToken() != JsonToken.END_ARRAY) {// hand each element to databind — hybrid streamingval record: Record = parser.readValueAs(Record::class.java)sum += record.amount}sum}}
Streaming a huge top-level array: JsonParser pulls one record at a time so peak memory stays flat. Called from a coroutine on Dispatchers.IO because the I/O is blocking.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport org.springframework.http.MediaTypeimport org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.RestControllerimport org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody@RestControllerclass ExportController(private val mapper: com.fasterxml.jackson.databind.ObjectMapper) {@GetMapping("/export", produces = [MediaType.APPLICATION_JSON_VALUE])fun export(): StreamingResponseBody = StreamingResponseBody { out ->mapper.factory.createGenerator(out).use { gen ->gen.writeStartArray()(1..1_000_000L).forEach { id ->gen.writeStartObject()gen.writeNumberField("id", id)gen.writeStringField("label", "row-$id")gen.writeEndObject()}gen.writeEndArray()}}}
Streaming output with JsonGenerator inside a Spring StreamingResponseBody: emit a large array while holding only one row in memory. use{} flushes and closes.
🧠Check your understanding
0/1 · 0/1 answered1. You must parse a 4 GB JSON file that is a single top-level array of order objects, summing one numeric field. Which Jackson approach is the most appropriate?