DEV Community

Cover image for 143 Faster JSONPath by Removing JSONPath
Yu Han
Yu Han

Posted on

143 Faster JSONPath by Removing JSONPath

This benchmark result surprised even me:

Implementation Time
Jayway JsonPath 143.477 ns/op
SJF4J Runtime JsonPath 26.284 ns/op
SJF4J APT-based @CompiledPath 0.988 ns/op
Native Java 0.877 ns/op

For this simple path:

$.store.bicycle.color
Enter fullscreen mode Exit fullscreen mode

@CompiledPath is roughly 143× faster than Jayway JsonPath and nearly identical to hand-written Java code.

How?

The answer is surprisingly simple:

@CompiledPath does not make JSONPath interpretation faster.

It removes JSONPath interpretation from the runtime entirely.


The Hidden Cost of JSONPath

Consider a typical JSONPath lookup:

String color = JsonPath.read(
    document,
    "$.store.bicycle.color"
);
Enter fullscreen mode Exit fullscreen mode

Even when the expression is precompiled, the runtime still needs to:

  • walk path segments;
  • inspect object types;
  • navigate containers;
  • resolve properties dynamically;
  • return and convert the result.

That flexibility is exactly what makes JSONPath useful.

But if the same path executes millions of times, the interpreter itself becomes part of the cost.

The JVM would much rather execute something like this:

String color = store.bicycle().color();
Enter fullscreen mode Exit fullscreen mode

No parser.

No AST.

No reflection.

No path interpreter.

Just Java.


Turning JSONPath into Java Code

With SJF4J, you declare a path interface:

@CompiledPath
public interface StorePaths {

    @GetByPath("$.store.bicycle.color")
    String color(BookStore root);

}
Enter fullscreen mode Exit fullscreen mode

And use it like any other Java API:

StorePaths paths = CompiledNodes.of(StorePaths.class);

String color = paths.color(store);
Enter fullscreen mode Exit fullscreen mode

During compilation, the annotation processor generates an implementation.

Conceptually, the generated code looks like this:

@Override
public String color(BookStore root) {
    if (root == null) return null;

    Store store = root.store();
    if (store == null) return null;

    Bicycle bicycle = store.bicycle();
    if (bicycle == null) return null;

    return bicycle.color();
}
Enter fullscreen mode Exit fullscreen mode

That's the entire trick.

There is no runtime JSONPath evaluation anymore.

The generated method is simply normal Java code.


Why It Gets So Close to Native Speed

The speedup comes from removing layers of work.

No Runtime Parser

The path expression is processed during compilation.

Nothing needs to be parsed when the application runs.

No Path Interpreter

There is no AST traversal loop.

There is no segment-dispatch mechanism.

There is no dynamic path evaluation.

Direct Property Access

The generated implementation can directly navigate:

  • POJOs
  • Records
  • Maps
  • Lists
  • JsonObject
  • JsonArray

using the most direct access strategy available.

JVM-Friendly Code

Because the generated code looks like ordinary Java methods, the JIT compiler can inline and optimize it aggressively.

From the JVM's perspective, there is nothing special happening.


Compile-Time Validation Included

Performance is only part of the story.

Because paths are analyzed during compilation, invalid paths fail the build.

Suppose your model contains:

store.book
Enter fullscreen mode Exit fullscreen mode

but you accidentally write:

@GetByPath("$.store.books.price")
Enter fullscreen mode Exit fullscreen mode

Instead of discovering the problem in production, compilation fails immediately:

Unknown property 'books' on Store
Enter fullscreen mode Exit fullscreen mode

The path becomes part of your application's contract.

If you've used MapStruct, QueryDSL, or jOOQ, the experience will feel familiar.


Reads and Writes

@CompiledPath supports both read and write operations.

@CompiledPath
public interface StorePaths {

    @GetByPath("$.store.book[{idx}].price")
    Double bookPrice(BookStore root, int idx);

    @PutByPath("$.store.book[{idx}].price")
    Double replaceBookPrice(
        BookStore root,
        int idx,
        Double value
    );

    @EnsurePutByPath("$.store.bicycle.price")
    Double ensureBicyclePrice(
        BookStore root,
        Double value
    );
}
Enter fullscreen mode Exit fullscreen mode

Dynamic placeholders are mapped directly from method parameters:

@GetByPath("$.users[{idx}].profile[{key}]")
Object value(Model root, int idx, String key);
Enter fullscreen mode Exit fullscreen mode
  • int parameters address array/list indexes
  • String parameters address object keys

The generated implementation performs direct navigation without evaluating a runtime expression tree.


Benchmark Snapshot

The following benchmark was executed against the same object graph:

Operation Runtime JsonPath @CompiledPath Native Java Speedup
get_bookPrice 92.485 ns/op 1.793 ns/op 1.627 ns/op 51.58×
get_color 91.491 ns/op 0.988 ns/op 0.877 ns/op 92.60×
get_price 80.996 ns/op 0.984 ns/op 0.949 ns/op 82.31×
put_bookPrice 99.874 ns/op 18.911 ns/op 18.697 ns/op 5.28×
put_price 86.607 ns/op 19.587 ns/op 19.157 ns/op 4.42×

The exact numbers depend on the model, JVM, hardware, and path shape.

The architectural takeaway is more important:

@CompiledPath moves path resolution from runtime to build time.


When Should You Use It?

@CompiledPath is a good fit when:

  • path expressions are known during development;
  • the same paths execute frequently;
  • performance matters;
  • compile-time safety is desirable.

Typical use cases include:

  • validation engines;
  • pricing systems;
  • routing rules;
  • DTO projections;
  • data transformation pipelines;
  • high-frequency service code.

For user-supplied or highly dynamic expressions, runtime JsonPath remains the right tool.

The two approaches are complementary.


Setup

Add the annotation processor:

dependencies {
    implementation("org.sjf4j:sjf4j:{version}")
    annotationProcessor("org.sjf4j:sjf4j-processor:{version}")
}
Enter fullscreen mode Exit fullscreen mode

The implementation classes are generated automatically during compilation.


The Real Idea

Most JSONPath libraries focus on evaluating path expressions as efficiently as possible.

@CompiledPath takes a different route.

Instead of optimizing the interpreter, it removes the interpreter.

The path expression becomes Java code.

And once that happens, the JVM can optimize it exactly like code you wrote yourself.

That is how a JSONPath expression ends up running at nearly the same speed as native Java.

Learn More

Top comments (0)