Networking & HTTP

Working with JSON

20 min Lesson 8 of 13

Working with JSON

JSON is the lingua franca of modern APIs. Every REST client you write will parse JSON responses and serialise Java objects into JSON request bodies. In this lesson you will learn how to do both professionally using Jackson, the de-facto standard library for JSON in the Java ecosystem, alongside the built-in org.json approach and a brief look at Gson so you can evaluate trade-offs and make informed choices.

Why Jackson?

Jackson is used by Spring Boot, Quarkus, Micronaut, and virtually every other major Java framework as their default JSON engine. It is battle-tested, extremely fast, and rich in features. The core artifact is jackson-databind, which pulls in jackson-core and jackson-annotations:

<!-- Maven --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.1</version> </dependency>
Jackson vs Gson vs org.json: Gson (Google) is simpler to embed in small projects with no framework. org.json is a lightweight, dependency-free option for ad-hoc parsing but has no data-binding. Jackson has the richest feature set and best performance at scale — prefer it for any production service.

The ObjectMapper — One Instance, Thread-Safe

ObjectMapper is Jackson's workhorse. It is expensive to create and thread-safe after configuration, so create a single shared instance (e.g. as a static final field or a singleton bean):

import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; public final class JsonMapper { // One shared instance — safe for concurrent use public static final ObjectMapper MAPPER = new ObjectMapper() .registerModule(new JavaTimeModule()) // support Instant, LocalDate, etc. .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); private JsonMapper() {} }
Never create a new ObjectMapper on every request. Each instantiation loads reflection metadata for every class it encounters, making it orders of magnitude slower than reusing a configured instance.

Deserialising JSON into Java Objects (Reading)

Given a JSON string from an HTTP response body, ObjectMapper.readValue() maps it to a typed Java object. First define the target class using a Java record (preferred in Java 17+) or a POJO with getters:

// Domain record — fields map to JSON keys by name public record Product( long id, String name, double price, int stock ) {}
import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; ObjectMapper mapper = JsonMapper.MAPPER; HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/products/42")) .header("Accept", "application/json") .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); // Deserialise the response body into a typed Product Product product = mapper.readValue(response.body(), Product.class); System.out.printf("Product: %s, Price: %.2f%n", product.name(), product.price());

Handling JSON Arrays and Generic Collections

When the API returns a JSON array, you need a TypeReference to preserve the generic type at runtime (Java erases generics at bytecode level, so List<Product>.class is not a valid expression):

import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; // Response body: [{"id":1,"name":"Keyboard",...}, {"id":2,"name":"Mouse",...}] List<Product> products = mapper.readValue( response.body(), new TypeReference<List<Product>>() {} ); products.forEach(p -> System.out.println(p.name()));
Use TypeReference for any generic type. It works equally well for Map<String, Object>, List<Map<String, String>>, or any other parameterised type. The anonymous subclass syntax {} at the end is intentional — it captures the generic type signature in bytecode so Jackson can read it via reflection.

Serialising Java Objects into JSON (Writing)

The reverse operation — turning a Java object into a JSON string for a request body — uses writeValueAsString():

public record NewProduct(String name, double price, int stock) {} // Build the Java object NewProduct payload = new NewProduct("Wireless Keyboard", 49.99, 200); // Serialise to JSON string String json = mapper.writeValueAsString(payload); // {"name":"Wireless Keyboard","price":49.99,"stock":200} HttpRequest postRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/products")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build();

Controlling the Mapping with Annotations

Real APIs rarely have JSON keys that match Java naming conventions perfectly. Jackson provides annotations to bridge the gap:

import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @JsonInclude(Include.NON_NULL) // omit null fields from serialised output public record UserProfile( @JsonProperty("user_id") long id, // maps to/from "user_id" in JSON @JsonProperty("full_name") String fullName, String email, @JsonIgnore String passwordHash // never serialised or deserialised ) {}
  • @JsonProperty("key") — renames the field in JSON. Essential when the API uses snake_case and Java uses camelCase.
  • @JsonIgnore — excludes the field from both serialisation and deserialisation. Use for sensitive data like password hashes.
  • @JsonInclude(NON_NULL) — suppresses null fields in the output, keeping the JSON compact.
  • @JsonAlias — accepts multiple names during deserialisation (useful when an API changes a field name and you must support both versions during a transition).

Global Snake-Case Naming Strategy

Rather than annotating every field, you can configure ObjectMapper globally to translate between camelCase (Java) and snake_case (API) automatically:

import com.fasterxml.jackson.databind.PropertyNamingStrategies; ObjectMapper mapper = new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); // Now "fullName" in Java <-> "full_name" in JSON automatically

Reading Arbitrary JSON with JsonNode

Sometimes you do not have — or do not want — a fixed schema. JsonNode gives you a dynamic tree model, similar to a DOM for JSON. This is valuable for inspecting API responses during development or for processing heterogeneous data:

import com.fasterxml.jackson.databind.JsonNode; JsonNode root = mapper.readTree(response.body()); // Navigate the tree String name = root.get("name").asText(); double price = root.get("price").asDouble(); boolean inStock = root.path("meta").path("in_stock").asBoolean(false); // path() never throws // Iterate a JSON array node JsonNode tags = root.get("tags"); if (tags != null && tags.isArray()) { for (JsonNode tag : tags) { System.out.println(tag.asText()); } }
Prefer path() over get() when navigating optional fields. get() returns null for a missing key, which will throw a NullPointerException on the next call. path() returns a MissingNode sentinel that is safe to traverse further and produces a default value when converted with asText(), asInt(), etc.

Producing JSON with ObjectNode

When you need to build a JSON payload dynamically (fields determined at runtime), use ObjectNode instead of string concatenation:

import com.fasterxml.jackson.databind.node.ObjectNode; ObjectNode body = mapper.createObjectNode(); body.put("event", "page_view"); body.put("user_id", 9001); body.put("timestamp", System.currentTimeMillis()); ObjectNode props = body.putObject("properties"); props.put("page", "/checkout"); props.put("referrer", "https://google.com"); String json = mapper.writeValueAsString(body); // {"event":"page_view","user_id":9001,"timestamp":...,"properties":{"page":"/checkout","referrer":"..."}}

Handling Unknown Fields Gracefully

APIs evolve and sometimes return fields your Java class does not model. By default, Jackson throws UnrecognizedPropertyException for unknown fields. In production code, configure the mapper to ignore them so your client is not broken by harmless API additions:

import com.fasterxml.jackson.databind.DeserializationFeature; ObjectMapper mapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
This is a double-edged sword. Ignoring unknown properties protects you from breakage, but it also means typos in field names during development silently produce empty/default fields instead of a clear error. During development, keep the default strict behaviour and switch to lenient only in your production configuration.

Jackson vs Gson — When to Choose What

  • Jackson — best choice for production services, framework integration, streaming large payloads, and rich annotation-based control.
  • Gson — simpler API, zero configuration for basic use, good for Android or small utilities where you cannot use the full Jackson stack.
  • org.json — dependency-free, good for scripting or tools where adding a library is not acceptable. Has no data-binding; you access fields manually by name.

Summary

You now have a complete toolkit for JSON in Java: create a single shared ObjectMapper, deserialise responses with readValue() and TypeReference, serialise payloads with writeValueAsString(), control mapping with annotations, navigate unknown structures with JsonNode, and build dynamic payloads with ObjectNode. These patterns cover the vast majority of real-world REST client JSON work. The next lesson ties everything together: building a complete, production-quality REST API client.