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.
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:
| Type | Public methods |
|---|---|
Json | parse(String), parse(char[]), toDisplayString(JsonValue, String) |
JsonValue | toString, asBoolean, asInt, asLong, asDouble, asString, asList, asMap, get(String), tryGet(String), get(int), tryValue() |
JsonObject | of(Map), asMap, get, tryGet |
JsonArray | of(List), asList, get(int) |
JsonString | of(String), asString |
JsonNumber | of(int), of(long), of(double), of(String), asInt, asLong, asDouble |
JsonBoolean | of(boolean), asBoolean |
JsonNull | of() |
JsonParseException | getErrorLine(), getErrorPosition() |
JsonValueException | message only |
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 theof(...) 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
BecauseJsonValue 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 fields | Yes. 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 InputStream | No. Only String or char[]. | Jackson streaming API |
| Process a document too large for memory | No. The whole tree is built in memory. | Jackson streaming API |
| Accept comments, trailing commas or JSON5 | No. Strict RFC 8259 only. | Jackson with a feature flag |
Spring Boot controllers and RestClient | No integration. | Jackson (Spring Boot’s default) |
| A script, a build tool or a test with no dependencies | Yes. 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.
No Comments yet!