Skip to main content

Java Finally Gets a JSON API: JEP 540 Simple JSON API (Incubator) in JDK 28

JDK 28 adds a small JSON parser and generator to the JDK itself (JEP 540, jdk.incubator.json). Compiled and run on JDK 28-ea+19: the tree model, typed access, error messages, pattern matching, number and duplicate-key rules, and when to still use Jackson.

Every Java developer has typed the same two lines at the start of a new project: add a JSON library to the build file, then work out which one. For a quick script that reads a config file or a small REST response, that is a lot of ceremony for a format that has had a published grammar (RFC 8259) for years. JDK 28 is the first release to change that. JEP 540 adds a small JSON parser and generator to the JDK itself, in an incubator module called jdk.incubator.json. This post runs it. Everything below was compiled and executed on an OpenJDK 28 early-access build (build 19, 8 October 2026), and every console block is the program’s real output.
Versions tested. OpenJDK 28-ea+19 from jdk.java.net (build 19, dated 2026/10/8). JEP 540 shows status “Integrated” for release 28 on openjdk.org. JDK 28 has not shipped; the build reports a release date of 2027-03-23. The API is an incubator API, so names and behaviour can change before and after GA — where something below looks odd, I say so.

The smallest correct mental model: six kinds of value

JSON has exactly six kinds of value: an object, an array, a string, a number, a boolean and null. JEP 540 gives each one a Java interface, and they all extend a seventh interface, JsonValue. You never construct a parser object or a configuration object. You call Json.parse(String), get a JsonValue back, and walk it.
JsonValue (sealed) JsonObjectJsonArrayJsonStringJsonNumberJsonBooleanJsonNull containers (green) leaf values (amber) Json.parse(text) returns the top of this tree; you pick the branch you expect.
The diagram is the whole type system. Green boxes hold other values, amber boxes hold a single piece of data. I confirmed the “sealed” part by running javap -v on JsonValue: its PermittedSubclasses attribute lists exactly these six. That matters later, because it lets the compiler check a switch for completeness.

Running it: an incubator module needs two flags

Incubator modules are not resolved by default. You must ask for the module at compile time and at run time:
$ javac --add-modules jdk.incubator.json -d out JsonDemo.java
warning: [incubating] using incubating module(s): jdk.incubator.json
1 warning
$ java --add-modules jdk.incubator.json -cp out JsonDemo
WARNING: Using incubator modules: jdk.incubator.json
Both warnings are expected. Without --add-modules, import jdk.incubator.json.* simply fails to resolve. The module itself exports a single package, jdk.incubator.json, and requires nothing but java.base (verified with java --describe-module jdk.incubator.json).
Do not ship this yet. An incubator API can change shape or be removed between releases, and your build needs the extra flag everywhere it compiles or runs. Use it for tools, tests and experiments on a JDK 28 early-access build. Production services should stay on a library until the API leaves incubation.
Download the code. The complete programs behind every output in this post: JsonDemo.java (sections on reading, errors, writing, numbers and duplicates) and Walk.java (the exhaustive pattern-matching switch). Save them in an empty directory and use the two commands shown above.

Reading JSON: parse once, then ask for what you expect

Here is the document used for all the reading examples, a small order:
static final String ORDER = """
    {
      "id": 1042,
      "customer": {"name": "Asha Patil", "vip": true, "email": null},
      "items": [
        {"sku": "JV-100", "qty": 2, "price": 499.5},
        {"sku": "JV-200", "qty": 1, "price": 1250}
      ],
      "notes": "Deliver after 6 PM"
    }
    """;
Navigation is a chain of get("name") and get(index) calls, finished with a typed accessor such as asInt() or asString(). A loop over an array uses asList():
JsonValue root = Json.parse(ORDER);
System.out.println("id            : " + root.get("id").asInt());
System.out.println("customer.name : " + root.get("customer").get("name").asString());
System.out.println("customer.vip  : " + root.get("customer").get("vip").asBoolean());
double total = 0;
for (JsonValue item : root.get("items").asList()) {
    total += item.get("qty").asInt() * item.get("price").asDouble();
}
System.out.println("order total   : " + total);
id            : 1042
customer.name : Asha Patil
customer.vip  : true
order total   : 2249.0
The code reads like a description of the document, which is the point the JEP makes: with no schema, the navigation code is the schema. The arithmetic checks out: 2 × 499.5 + 1 × 1250 = 2249.

Optional fields: tryGet

get("coupon") on a missing member throws. When a field may legitimately be absent, use tryGet, which returns an Optional<JsonValue>:
System.out.println("notes         : " + root.tryGet("notes").map(JsonValue::asString).orElse("(none)"));
System.out.println("coupon        : " + root.tryGet("coupon").map(JsonValue::asString).orElse("(none)"));
System.out.println("customer.email present? " + root.get("customer").tryGet("email").isPresent());
System.out.println("customer.email class    : " + root.get("customer").get("email").getClass().getInterfaces()[0].getSimpleName());
notes         : Deliver after 6 PM
coupon        : (none)
customer.email present? true
customer.email class    : JsonNull
The last two lines are the trap. "email": null is a member that exists, so tryGet returns a present Optional holding a JsonNull. “Absent” and “present but null” are different states in JSON, and the API keeps them different. If your data uses null to mean “not set”, check for JsonNull explicitly.
Going deeper: the full public API surface (from javap)
I listed every public type with javap --module jdk.incubator.json. The whole API is small enough to hold in your head:
TypePublic methods
Jsonparse(String), parse(char[]), toDisplayString(JsonValue, String)
JsonValuetoString, asBoolean, asInt, asLong, asDouble, asString, asList, asMap, get(String), tryGet(String), get(int), tryValue()
JsonObjectof(Map), asMap, get, tryGet
JsonArrayof(List), asList, get(int)
JsonStringof(String), asString
JsonNumberof(int), of(long), of(double), of(String), asInt, asLong, asDouble
JsonBooleanof(boolean), asBoolean
JsonNullof()
JsonParseExceptiongetErrorLine(), getErrorPosition()
JsonValueExceptionmessage only
Two things are missing from Json that you might expect. There is no overload that reads from a Reader or InputStream: you read the file or response into a String (or char[]) first. And there is no method that takes a Java object and turns it into JSON. Both omissions match the JEP’s stated goals, which exclude data binding and streaming. I did not exercise tryValue(), so I make no claim about its behaviour.

When the data is not what you expected

Real JSON is rarely what the documentation said it would be. The API has two exception types, and the difference is useful. JsonParseException means the text is not valid JSON. JsonValueException means the text parsed fine but you asked for something it does not hold.
try { root.get("customer").asString(); }
catch (RuntimeException e) { System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); }
try { root.get("missing"); }
catch (RuntimeException e) { System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); }
try { root.get("items").get(7); }
catch (RuntimeException e) { System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); }
JsonValueException: JsonObject is not a JsonString. Path: "{customer". Location: line 2, position 14.
JsonValueException: JsonObject member "missing" does not exist. Path: "". Location: line 0, position 0.
JsonValueException: JsonArray index 7 out of bounds for length 2. Path: "{items". Location: line 3, position 11.
These messages are better than the usual ClassCastException or NullPointerException, because they name the type you hit, the path you took, and the position in the source text. Two oddities in build 19 are worth knowing. Line numbers are zero-based: in the text block above, "customer" is on the third line and is reported as line 2. And the Path format, such as "{customer", is compact but unusual. I would not parse it; treat it as display text. Both could change before GA.

Invalid JSON is rejected strictly

The parser follows RFC 8259 and nothing more. I fed it eight common near-misses:
{"a": 1,}      -> line 0, pos 8: Expecting a JSON Object member name. Path: "{". Location: line 0, position 8.
{"a": }        -> line 0, pos 6: Unexpected value. Expected a JSON Object, Array, String, Number, Boolean, or Null. Path: "{a". Location: line 0, position 6.
[1, 2          -> line 0, pos 5: JSON Array is not closed with a bracket. Path: "[". Location: line 0, position 5.
{'a': 1}       -> line 0, pos 1: Expecting a JSON Object member name. Path: "{". Location: line 0, position 1.
{"a": 01}      -> line 0, pos 7: Invalid position of '0' within JSON Number. Path: "{a". Location: line 0, position 7.
// c\n{}       -> line 0, pos 0: Unexpected value. Expected a JSON Object, Array, String, Number, Boolean, or Null. Path: "". Location: line 0, position 0.
{"a": NaN}     -> line 0, pos 6: Unexpected value. Expected a JSON Object, Array, String, Number, Boolean, or Null. Path: "{a". Location: line 0, position 6.
               -> line 0, pos 0: Expected a JSON Object, Array, String, Number, Boolean, or Null. Path: "". Location: line 0, position 0.
Read the list as a checklist of what this parser will not accept: a trailing comma, single-quoted names, a leading zero, a // comment, NaN, and an empty string. If you are used to lenient parsers, a hand-edited config file with a comment in it will fail here. That strictness is deliberate: the JEP’s goals exclude “syntax extensions” such as JSON5.

Writing JSON: build values, then print

Generation is the mirror of reading. You build a tree from the of(...) factories and call toString() for compact output or Json.toDisplayString(value, indent) for pretty output:
Map<String, JsonValue> m = new LinkedHashMap<>();
m.put("name", JsonString.of("Ravi"));
m.put("age", JsonNumber.of(31));
m.put("active", JsonBoolean.of(true));
m.put("tags", JsonArray.of(List.of(JsonString.of("java"), JsonString.of("json"))));
m.put("spouse", JsonNull.of());
JsonObject out = JsonObject.of(m);
System.out.println(out);
System.out.println(Json.toDisplayString(out, "  "));
{"name":"Ravi","age":31,"active":true,"tags":["java","json"],"spouse":null}
{
  "name": "Ravi",
  "age": 31,
  "active": true,
  "tags": [
    "java",
    "json"
  ],
  "spouse": null
}
I used a LinkedHashMap deliberately. My first run passed Map.of(...) and the members came out in an arbitrary order, because Map.of has no defined iteration order. The API prints members in the order the map iterates, so if the order of keys in your output matters (for diffing or for humans), give it an ordered map.

Pattern matching over the tree

Because JsonValue is sealed with six permitted types, a switch over it can be exhaustive with no default branch. If a future JDK added a seventh kind of value, this code would stop compiling rather than silently miss a case:
static String describe(JsonValue v) {
    return switch (v) {
        case JsonObject o  -> "object(" + o.asMap().size() + " members)";
        case JsonArray a   -> "array(" + a.asList().size() + " elements)";
        case JsonString s  -> "string(" + s.asString() + ")";
        case JsonNumber n  -> "number(" + n + ")";
        case JsonBoolean b -> "boolean(" + b.asBoolean() + ")";
        case JsonNull nl   -> "null";
    };
}
a -> array(3 elements)
b -> string(hi)
c -> boolean(true)
d -> null
e -> object(1 members)
This is the recommended way to write a generic walker, such as a pretty-printer, a validator or a “find every string” search, without a pile of instanceof checks.

Numbers, duplicates and other sharp edges

1  asDouble=1.0
1.5  asDouble=1.5
12345678901234  asDouble=1.2345678901234E13
1e400  asDouble -> JsonValueException: 1e400 cannot be represented as a double. Path: "[3". Location: line 0, position 25.
0.1  asDouble=0.1
1.5 asInt -> JsonValueException: 1.5 cannot be represented as an int. Path: "[1". Location: line 0, position 4.
12345678901234 asInt -> JsonValueException: 12345678901234 cannot be represented as an int. Path: "[2". Location: line 0, position 9.
12345678901234 asLong = 12345678901234
The number rules are strict and worth internalising. A JSON number parses even if it is huge (1e400 is valid JSON text), but asking for it as a double throws instead of quietly returning infinity. asInt() on 1.5 throws instead of truncating, and asInt() on a value that does not fit in 32 bits throws instead of wrapping. The first time you read an ID like 12345678901234 you will want asLong().
asMap().put -> UnsupportedOperationException
duplicate key -> JsonParseException: Duplicate member name: "a" was already parsed. Path: "{". Location: line 0, position 7.
key order: {"z":1,"a":2,"m":3}
Three more facts from the same run. The map returned by asMap() is unmodifiable, so you build new documents rather than editing a parsed one. A duplicate key is an error, not “last one wins” as in some libraries. And member order from the input text is preserved when you print it back.
Going deeper: escapes and the encoding trap
I parsed a string containing a Unicode escape, quotes, a newline and a tab, then printed it two ways:
caf? "q" 
 tab	
{"s":"caf\u00e9 \"q\" \n tab\t"}
The ? on the first line comes from my sandbox’s default console charset, not from the parser. The second line is the useful one: toString() writes the non-ASCII letter back as the escape \u00e9 and re-escapes the quotes, newline and tab, so the printed text is safe to move through a channel that is not UTF-8 clean. If you need readable non-ASCII output, take it from asString() on a value and print that yourself.

What it deliberately does not do, and when to still use Jackson

The JEP is explicit that this API is not trying to replace Jackson, Gson or Jakarta JSON. Its stated non-goals are data binding, streaming, multiple parser configurations and syntax extensions. That makes the decision simple:
You need…JEP 540 (JDK 28)Use instead
Read a small document or response and pull out a few fieldsYes. This is the target use case.—
Convert JSON to a POJO or record (data binding)No. There is no mapping API.Jackson ObjectMapper
Parse straight from a file or InputStreamNo. Only String or char[].Jackson streaming API
Process a document too large for memoryNo. The whole tree is built in memory.Jackson streaming API
Accept comments, trailing commas or JSON5No. Strict RFC 8259 only.Jackson with a feature flag
Spring Boot controllers and RestClientNo integration.Jackson (Spring Boot’s default)
A script, a build tool or a test with no dependenciesYes. Nothing to download.—
Should you adopt it? Not in an application that already has Jackson. It will not replace data binding, and mixing two JSON stacks costs more than it saves. Try it where a dependency is unwelcome: a single-file Java program, a build or CI helper, or a test that asserts on a response body.
If you stay on Jackson, these existing posts cover the parts the JDK API leaves out: the ObjectMapper guide, the streaming API and JsonNode tree model, Jackson with records and sealed classes, and Jackson 3 vs Jackson 2. Note that Jackson’s own tree model, JsonNode, is the closest equivalent to what JEP 540 offers.
Reproduce it yourself: complete setup
1. Download the JDK 28 early-access build from jdk.java.net/28 and unpack it. 2. Save the snippets above into one class, or just the describe method into a second file. 3. Compile and run with the two flags shown earlier. Because early-access builds change weekly, your messages may differ slightly from mine; if they do, the build number is the first thing to compare. All outputs in this post came from build 28-ea+19-1485 (java -version reports openjdk version "28-ea" 2027-03-23). This post has no companion repository. Instead, the two complete source files are in the WordPress media library: JsonDemo.java and Walk.java. The exact outputs are quoted in place.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.