Using JsonPath safely with Jackson’s JsonNode tree model

When working with JSON in Java, Jackson’s JsonNode tree model is a popular choice — especially in framework code that processes or transforms data. But what if you want to query that tree using flexible JsonPath expressions?

Jayway JsonPath is a powerful tool, but using it with pre-parsed Jackson trees isn’t as obvious as you’d think — especially when you’re aiming for safe, predictable behavior in production.

In this post, I’ll share a few techniques I found helpful for reliably applying JsonPath to JsonNode trees, making your queries safer, clearer, and better suited to framework-level code.

Using JsonPath with JsonNode trees

Jayway JsonPath offers an obvious API to parse JSON from scratch (from a file, string or InputStream) but is less obvious how to use with an already-parsed tree.

Here’s how to set it up:

Configuration config = Configuration.builder()
    .jsonProvider(new JacksonJsonNodeJsonProvider())
    .mappingProvider(new JacksonMappingProvider())
    .build();

Using JsonPath to query for JsonNodes directly

The JayWay JsonPath API seems originally designed to support application-code query usecases where the types are simple & known for each query. It uses generic value coercion, which “magically” tries to cast simple values automatically; but leads to quite many surprises.

The API can take a ‘type’ Class parameter to control answer type. This works for simple values (strings or ints, etc), but when you want a plural result (a List) it cannot control the type of the list items.

Fortunately JayWay have added a workaround – the ‘type’ TypeRef parameter.

Here’s how to use it:

List<JsonNode> authors = JsonPath
    .using(config)
    .parse(jsonNode)
    .read("$.store.book[*].author", new TypeRef<List<JsonNode>>() {});

Clarity on Plurality

At an end-user level, I believe it’s important that APIs are clear about plurality; whether a result should be singular or many.

Ideally an API should offer distinct method signatures for these two cases; for example:

  • a ‘getValue()’ method answering a single result, possibly with defaulting,
  • a ‘getValues()’ method answering a list which is empty if nothing found.

The JayWay JsonPath API tends to blur these cases. Surprises are common like:

  • A single match returning a String, but multiple matches returning a List.
  • A missing match returning null, an empty list, or even throwing an exception — depending on configuration
  • Empty results for scalar paths (like "$.foo.bar") may throw a PathNotFoundException.

For these reasons, I generally recommend to wrap JayWay JsonPath usage within an application to help distinguish single-value from list semantics. While it’s possible to use a static helper, it’s better API to design an instantiated object which can hold context.

Here’s an example helper:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.*;
import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider;
import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;

import java.util.Collections;
import java.util.List;

public class JsonPather {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    private static final Configuration pathConfig = Configuration.builder()
            .jsonProvider(new JacksonJsonNodeJsonProvider(objectMapper))
            .mappingProvider(new JacksonMappingProvider(objectMapper))
            .options(Option.SUPPRESS_EXCEPTIONS)
            .build();

    private final DocumentContext context;

    public JsonPather(JsonNode root) {
        this.context = JsonPath.using(pathConfig).parse(root);
    }

    /** Get a single typed value at the given JSONPath. */
    public <T> T getValue(String jsonPath, Class<T> type) {
        try {
            return context.read(jsonPath, type);
        } catch (PathNotFoundException e) {
            return null;
        }
    }

    /** Get a single typed value with fallback default. */
    public <T> T getValue(String jsonPath, Class<T> type, T defaultVal) {
        T result = getValue(jsonPath, type);
        return (result != null) ? result : defaultVal;
    }

    /** Get a list of typed values by converting matching JsonNodes. */
    public <T> List<T> getValues(String jsonPath, Class<T> type) {
        List<JsonNode> nodes = getNodes(jsonPath);
        return nodes.stream()
                .map(node -> objectMapper.convertValue(node, type))
                .toList();
    }
    
    // --------------------------------------------------------------


    /** Get a single JsonNode at the given JSONPath. */
    public JsonNode getNode(String jsonPath) {
        return context.read(jsonPath, JsonNode.class);
    }

    /** Get list of JsonNode values matching the JSONPath. */
    public List<JsonNode> getNodes(String jsonPath) {
        try {
            return context.read(jsonPath, new TypeRef<List<JsonNode>>() {});
        } catch (PathNotFoundException e) {
            return Collections.emptyList();
        }
    }
    
}

Usage is simple:

JsonPather pather = new JsonPather(root);
String author = pather.getValue("$.store.book[0].author", String.class);
List<String> authors = pather.getValues("$.store.book[*].author", String.class);

Glad you made it this far! If you’ve got tips, edge cases, or battle scars from working with JsonPath and Jackson, I’m all ears. Let’s swap ideas and make our parsers a little sharper.

Leave a Reply

Your email address will not be published. Required fields are marked *