Producing JSON by Hand
Write valid JSON without a library for small outputs, escape strings correctly, and avoid the number and injection traps that break clients.
String json = "{\"name\":" + quote(name) + ",\"total\":" + total.toPlainString() + "}";
byte[] body = json.getBytes(StandardCharsets.UTF_8);Java 25 has no JSON reader or writer in the standard library. Most services add Jackson or a similar library, covered in the next lesson. Still, there are good reasons to write small amounts of JSON yourself: a health endpoint, a structured log line, a single-file tool with no dependencies, or a test. Doing it safely comes down to a few rules from RFC 8259.
Strings must be escaped
A JSON string is wrapped in double quotes. Inside it you must escape the double quote as \", the backslash as \\, and every control character below U+0020. Newline, carriage return and tab have short forms (\n, \r, \t); the rest are written as \u0000 style escapes. Other characters, including accented letters and emoji, can stay as they are, provided the bytes you send are UTF-8.
Building JSON with string concatenation or String.format and no escaping is the classic mistake. A name containing a quote produces invalid JSON and a client error. Worse, a value such as x","role":"admin adds a field of the attacker's choosing, which is JSON injection. Every string that did not come from a literal in your code must go through an escape function.
Numbers have their own traps
NaNandInfinityare not valid JSON.Double.toStringproduces them, and many parsers reject the whole document.- JavaScript parses every JSON number as a double, which holds integers exactly only up to 253. A
longID above that arrives rounded, so send large IDs as strings. - For money, write a
BigDecimalwithtoPlainString(), or send it as a string;toString()can produce exponent notation such as1E+3.
null and missing are different
"coupon":null says the field exists and has no value; leaving the key out says nothing about it. Decide which one your API means and keep it consistent, because clients branch on the difference.
Sending it
Encode the finished string with getBytes(StandardCharsets.UTF_8), set Content-Type: application/json, and use the byte length, not String.length(), for Content-Length. They differ as soon as the text holds a non-ASCII character, and a wrong length truncates the response or leaves the client waiting.
Where to stop
Writing JSON is easy to get right with one escape function. Parsing JSON by hand is not: nested structures, escapes, numbers and malicious input make it a job for a library. Once you are mapping request bodies to objects, adopt one.
Example
When to use it
- Return a small health or version JSON document from a dependency-free service built on the JDK HTTP server.
- Write one JSON object per line to a log file so a log pipeline can index each field.
- Send 64-bit database IDs to a browser client as strings so they are not rounded by JavaScript.
More examples
A small writer for maps, lists and values
A pattern-matching switch keeps the writer short; the guarded Double and Float cases must come before Number, which would otherwise dominate them and fail to compile. Using a LinkedHashMap instead of Map.of gives a fixed key order, which keeps responses and test snapshots stable.
static String quote(String s) {
StringBuilder out = new StringBuilder("\"");
for (char ch : s.toCharArray()) {
switch (ch) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
default -> out.append(ch < 0x20 ? "\\u%04x".formatted((int) ch) : String.valueOf(ch));
}
}
return out.append('"').toString();
}
static void write(Object value, StringBuilder out) {
switch (value) {
case null -> out.append("null");
case String s -> out.append(quote(s));
case Boolean b -> out.append(b);
case BigDecimal d -> out.append(d.toPlainString());
case Double d when !Double.isFinite(d) ->
throw new IllegalArgumentException("JSON has no NaN or Infinity");
case Float f when !Float.isFinite(f) ->
throw new IllegalArgumentException("JSON has no NaN or Infinity");
case Number n -> out.append(n);
case Map<?, ?> map -> {
out.append('{');
boolean first = true;
for (var e : map.entrySet()) {
if (!first) out.append(',');
first = false;
out.append(quote(String.valueOf(e.getKey()))).append(':');
write(e.getValue(), out);
}
out.append('}');
}
case List<?> list -> {
out.append('[');
for (int i = 0; i < list.size(); i++) {
if (i > 0) out.append(',');
write(list.get(i), out);
}
out.append(']');
}
default -> throw new IllegalArgumentException("cannot write " + value.getClass().getSimpleName());
}
}
// LinkedHashMap keeps keys in insertion order, so the output is stable.
static Map<String, Object> obj(Object... keysAndValues) {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i < keysAndValues.length; i += 2) {
map.put((String) keysAndValues[i], keysAndValues[i + 1]);
}
return map;
}
void main() {
var order = obj(
"id", "ord_1001",
"total", new BigDecimal("19.90"),
"paid", true,
"coupon", null,
"items", List.of(obj("sku", "SKU-1", "qty", 2), obj("sku", "SKU-9", "qty", 1)));
StringBuilder out = new StringBuilder();
write(order, out);
IO.println(out);
try {
write(obj("ratio", 0.0 / 0.0), new StringBuilder());
} catch (IllegalArgumentException e) {
IO.println("error: " + e.getMessage());
}
}
// Output:
// {"id":"ord_1001","total":19.90,"paid":true,"coupon":null,"items":[{"sku":"SKU-1","qty":2},{"sku":"SKU-9","qty":1}]}
// error: JSON has no NaN or InfinityServing JSON with the right bytes and headers
The first line shows why the length passed to sendResponseHeaders must come from the encoded bytes. This quote helper only escapes quotes and backslashes, which is enough for these fixed values but not for user input that may contain control characters.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
void main() throws Exception {
String name = "Zo\u00eb";
IO.println("chars " + name.length() + ", UTF-8 bytes " + name.getBytes(StandardCharsets.UTF_8).length);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/health", exchange -> {
String json = "{\"status\":" + quote("UP") + ",\"service\":" + quote("orders") + "}";
byte[] body = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, body.length); // the byte count, not json.length()
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
});
server.start();
try (HttpClient client = HttpClient.newHttpClient()) {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/health");
HttpResponse<String> res = client.send(HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(res.statusCode() + " " + res.headers().firstValue("Content-Type").orElse(""));
IO.println(res.body());
} finally {
server.stop(0);
}
}
// Output:
// chars 3, UTF-8 bytes 4
// 200 application/json; charset=utf-8
// {"status":"UP","service":"orders"}Injection and precision traps
The naive line is valid JSON with an extra role field that the attacker chose. Converting the long to a double shows what a browser's JSON.parse does with it, which is why many public APIs send large IDs as strings.
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
void main() {
String name = "x\",\"role\":\"admin";
IO.println("naive: {\"name\":\"" + name + "\"}");
IO.println("escaped: {\"name\":" + quote(name) + "}");
long id = 9_007_199_254_740_993L; // 2^53 + 1
IO.println("as number: {\"id\":" + id + "}");
IO.println("a JavaScript client reads: " + (long) (double) id);
IO.println("as string: {\"id\":\"" + id + "\"}");
}
// Output:
// naive: {"name":"x","role":"admin"}
// escaped: {"name":"x\",\"role\":\"admin"}
// as number: {"id":9007199254740993}
// a JavaScript client reads: 9007199254740992
// as string: {"id":"9007199254740993"}
Discussion