JSON #

JSON (JavaScript Object Notation) is the dominant data exchange format on the web today — almost every REST API uses it. In Java, the two most widely used libraries for working with JSON are Jackson and Gson. Jackson is the default choice for Spring Boot and the enterprise ecosystem, while Gson is lighter and often used on Android or projects that don’t need Jackson’s full feature set. This article covers serializing Java objects into JSON strings, deserializing JSON back into objects, customizing field naming, handling optional fields, generic types, dates, and how to parse JSON dynamically without a known class structure.

Overview #

The two main operations in working with JSON:

OperationDirectionJacksonGson
SerializationJava object → JSON stringobjectMapper.writeValueAsString(obj)gson.toJson(obj)
DeserializationJSON string → Java objectobjectMapper.readValue(json, Class.class)gson.fromJson(json, Class.class)
flowchart LR
    A["Java Object\n(POJO / Record)"] -->|"Serialization"| B["JSON String<br/>{&quot;name&quot;:&quot;Laptop&quot;}"]
    B -->|"Deserialization"| A
    C["File / HTTP Response\nInputStream"] -->|"readValue()"| A
    A -->|"writeValue()"| D["File / HTTP Body\nOutputStream"]

Jackson #

Jackson is the most popular JSON library in the Java ecosystem. Spring Boot uses it by default. Its main class is ObjectMapper, which is thread-safe and expensive to create — always make it a singleton or let Spring manage it.

Dependencies #

<!-- Maven -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.1</version>
</dependency>

<!-- For Java 8 date/time support (LocalDate, LocalDateTime, etc.) -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.17.1</version>
</dependency>
// Gradle
implementation 'com.fasterxml.jackson.databind:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'

In Spring Boot projects, Jackson is already available automatically via spring-boot-starter-web — no need to add dependencies manually.

Serialization — Objects to JSON #

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

// ObjectMapper is thread-safe — create once, use repeatedly
ObjectMapper mapper = new ObjectMapper();

// A simple model (can be a POJO or record)
record Product(Long id, String name, double price, int stock) {}

Product laptop = new Product(1L, "Laptop", 12_000_000, 5);

// Serialize to a String
String json = mapper.writeValueAsString(laptop);
// {"id":1,"name":"Laptop","price":12000000.0,"stock":5}

// Serialize with pretty print (indentation, easier to read)
String prettyJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(laptop);
/*
{
  "id" : 1,
  "name" : "Laptop",
  "price" : 12000000.0,
  "stock" : 5
}
*/

// Serialize to a file
mapper.writeValue(new java.io.File("product.json"), laptop);

// Serialize to a byte array (for HTTP response bodies)
byte[] bytes = mapper.writeValueAsBytes(laptop);

// Serialize a List
List<Product> list = List.of(laptop, new Product(2L, "Mouse", 150_000, 20));
String listJson = mapper.writeValueAsString(list);
// [{"id":1,"name":"Laptop",...},{"id":2,"name":"Mouse",...}]

Deserialization — JSON to Objects #

String json = """
    {
        "id": 1,
        "name": "Laptop",
        "price": 12000000.0,
        "stock": 5
    }
    """;

// Deserialize to a specific class
Product product = mapper.readValue(json, Product.class);
System.out.println(product.name()); // Laptop

// Deserialize from a file
Product fromFile = mapper.readValue(new java.io.File("product.json"), Product.class);

// Deserialize from a URL (fetch and parse directly)
Product fromURL = mapper.readValue(new java.net.URL("https://api.example.com/products/1"), Product.class);

// Deserialize a List
String listJson = "[{\"id\":1,\"name\":\"Laptop\"},{\"id\":2,\"name\":\"Mouse\"}]";
List<Product> list = mapper.readValue(listJson,
    mapper.getTypeFactory().constructCollectionType(List.class, Product.class));

// Or with a TypeReference (cleaner)
import com.fasterxml.jackson.core.type.TypeReference;
List<Product> list2 = mapper.readValue(listJson, new TypeReference<List<Product>>() {});

Handling Unknown Fields #

// ANTI-PATTERN: JSON has a field that doesn't exist in the class → UnrecognizedPropertyException
String jsonWithNewField = """
    {"id": 1, "name": "Laptop", "price": 12000000.0, "stock": 5, "category": "electronics"}
    """;
// If Product doesn't have a "category" field → error!

// CORRECT: ignore unknown fields
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

// Or with a class annotation
@JsonIgnoreProperties(ignoreUnknown = true)
public class Product { /* ... */ }

Jackson Annotations #

Jackson annotations control how objects are serialized and deserialized without changing the class structure.

@JsonProperty — Field Naming #

import com.fasterxml.jackson.annotation.*;

public class User {
    private Long id;

    // The JSON field name differs from the Java field name
    @JsonProperty("full_name")       // JSON: "full_name", Java: fullName
    private String fullName;

    @JsonProperty("email_address")
    private String email;

    @JsonProperty("created_at")
    private LocalDateTime createdAt;

    // Getters and setters...
}
// Serialization result:
// {"id":1,"full_name":"Budi Santoso","email_address":"[email protected]","created_at":"2025-08-17T10:00:00"}

@JsonIgnore and @JsonIgnoreProperties #

public class BankAccount {
    private Long id;
    private String accountNumber;

    @JsonIgnore // this field never goes into JSON (output or input)
    private String secretPin;

    @JsonIgnore
    private String passwordHash;
}

// Class-level annotation — ignore specific fields by name
@JsonIgnoreProperties({"secretPin", "passwordHash", "internalId"})
public class BankAccountV2 {
    private Long id;
    private String accountNumber;
    private String secretPin;  // will be ignored
}

@JsonInclude — Which Fields to Include #

import com.fasterxml.jackson.annotation.JsonInclude;

// Don't include null fields in the JSON output
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseDTO {
    private String data;
    private String error;     // null on success → doesn't appear in JSON
    private Integer total;    // null when irrelevant → doesn't appear
}

// Other options:
// NON_NULL    : skip null fields
// NON_EMPTY   : skip null and empty strings/collections
// NON_DEFAULT : skip default values (0, false, null, "")
// ALWAYS      : always include (default)

// Or a global ObjectMapper configuration
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

@JsonAlias — Accepting Multiple Names #

public class Product {
    // Accepts "product_name", "productName", or "nama" from JSON input
    @JsonAlias({"product_name", "productName"})
    @JsonProperty("name") // but the output always uses "name"
    private String name;
}

@JsonCreator and @JsonValue #

// @JsonValue: use one field as the JSON representation of the entire object
public enum OrderStatus {
    CREATED("created"),
    PROCESSING("processing"),
    SHIPPED("shipped"),
    COMPLETED("completed");

    private final String code;

    OrderStatus(String code) { this.code = code; }

    @JsonValue // serialize the enum as the code string, not the enum name
    public String getCode() { return code; }

    @JsonCreator // deserialize the code string back into an enum
    public static OrderStatus fromCode(String code) {
        for (OrderStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        throw new IllegalArgumentException("Invalid code: " + code);
    }
}

// Result: {"status":"shipped"} not {"status":"SHIPPED"}

@JsonSerialize and @JsonDeserialize #

import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;

// Custom serializer for price formatting
public class PriceSerializer extends StdSerializer<Double> {
    public PriceSerializer() { super(Double.class); }

    @Override
    public void serialize(Double value, JsonGenerator gen, SerializerProvider provider)
            throws IOException {
        gen.writeString("Rp" + String.format("%,.0f", value));
    }
}

public class Product {
    private String name;

    @JsonSerialize(using = PriceSerializer.class)
    private Double price;
}
// Result: {"name":"Laptop","price":"Rp12.000.000"}

Generic Types #

Deserializing to generic types like List<T> or Map<String, T> requires runtime type information that can’t be obtained from just Class.class because Java erases generic information at runtime (type erasure).

TypeReference for Complex Types #

import com.fasterxml.jackson.core.type.TypeReference;

String jsonList = "[{\"id\":1,\"name\":\"Laptop\"},{\"id\":2,\"name\":\"Mouse\"}]";
String jsonMap  = "{\"laptop\":{\"id\":1,\"price\":12000000},\"mouse\":{\"id\":2,\"price\":150000}}";

// List<Product> — use a TypeReference
List<Product> list = mapper.readValue(jsonList, new TypeReference<List<Product>>() {});

// Map<String, Product>
Map<String, Product> byName = mapper.readValue(jsonMap, new TypeReference<Map<String, Product>>() {});

// JavaType — a programmatic alternative
JavaType listType = mapper.getTypeFactory()
    .constructCollectionType(List.class, Product.class);
List<Product> list2 = mapper.readValue(jsonList, listType);

// Nested types: List<Map<String, Product>>
JavaType innerMap  = mapper.getTypeFactory().constructMapType(Map.class, String.class, Product.class);
JavaType outerList = mapper.getTypeFactory().constructCollectionType(List.class, innerMap);

// Nested TypeReference
Map<String, List<Product>> grouped = mapper.readValue(json,
    new TypeReference<Map<String, List<Product>>>() {});

Date and Time Handling #

By default, Jackson serializes LocalDate and LocalDateTime as number arrays [2025, 8, 17] — not an easily readable string format. The JSR-310 module fixes this.

Configuring ObjectMapper for java.time #

import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.databind.SerializationFeature;

ObjectMapper mapper = new ObjectMapper()
    .registerModule(new JavaTimeModule())
    // Disable timestamp serialization — use ISO-8601 strings instead
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

record Event(String name, LocalDate date, LocalDateTime time) {}

Event event = new Event("Java Conference", LocalDate.of(2025, 8, 17),
    LocalDateTime.of(2025, 8, 17, 9, 0));

String json = mapper.writeValueAsString(event);
// {"name":"Java Conference","date":"2025-08-17","time":"2025-08-17T09:00:00"}

Custom Date Formats #

import com.fasterxml.jackson.annotation.JsonFormat;

public class Transaction {
    private Long id;

    @JsonFormat(pattern = "dd/MM/yyyy")
    private LocalDate date;

    @JsonFormat(pattern = "dd/MM/yyyy HH:mm:ss")
    private LocalDateTime time;

    @JsonFormat(shape = JsonFormat.Shape.NUMBER)
    private Instant timestamp; // serialize as epoch millis
}

Dynamic JSON Parsing with JsonNode #

Sometimes you don’t know the JSON structure in advance — for example when handling webhooks or varying API responses. Use JsonNode as a navigable tree.

Reading and Navigating JsonNode #

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;

String json = """
    {
        "id": 1,
        "name": "Laptop",
        "price": 12000000,
        "available": true,
        "specifications": {
            "ram": "16GB",
            "storage": "512GB SSD"
        },
        "tags": ["electronics", "computer", "work"]
    }
    """;

JsonNode root = mapper.readTree(json);

// Access fields
long id         = root.get("id").asLong();         // 1
String name     = root.get("name").asText();        // "Laptop"
double price    = root.get("price").asDouble();     // 12000000.0
boolean available = root.get("available").asBoolean(); // true

// Nested objects
JsonNode specs = root.get("specifications");
String ram     = specs.get("ram").asText();      // "16GB"
String storage = specs.get("storage").asText();  // "512GB SSD"

// Arrays
JsonNode tags = root.get("tags");
for (JsonNode tag : tags) {
    System.out.println(tag.asText()); // electronics, computer, work
}

// Check whether a field exists
boolean hasDiscount = root.has("discount");       // false
boolean isNull    = root.get("id").isNull();  // false

// Optional path — doesn't throw if the field doesn't exist
String optional = root.path("notThere").asText("default"); // "default"

// Safe nested path navigation
String ram2 = root.at("/specifications/ram").asText(); // "16GB"
String notThere = root.at("/specifications/color").asText("black"); // "black" (default)

Creating and Modifying JsonNode #

import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;

// Build JSON from scratch programmatically
ObjectNode root = mapper.createObjectNode();
root.put("id", 1);
root.put("name", "Laptop");
root.put("price", 12_000_000.0);
root.put("available", true);

// Nested objects
ObjectNode specs = root.putObject("specifications");
specs.put("ram", "16GB");
specs.put("storage", "512GB SSD");

// Arrays
ArrayNode tags = root.putArray("tags");
tags.add("electronics");
tags.add("computer");

String resultJson = mapper.writeValueAsString(root);

// Change an existing field
((ObjectNode) root).put("price", 11_000_000.0); // overwrite the value
((ObjectNode) root).remove("available");          // delete a field

// Merge two JsonNodes
ObjectNode additional = mapper.createObjectNode();
additional.put("discount", 10);
root.setAll(additional); // add all fields from additional into root

Gson #

Gson is Google’s library, lighter than Jackson. It doesn’t need annotations or configuration for simple cases, but its feature set isn’t as complete as Jackson’s.

Dependencies #

<!-- Maven -->
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.10.1</version>
</dependency>
// Gradle
implementation 'com.google.code.gson:gson:2.10.1'

Basic Serialization and Deserialization #

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

// Gson is thread-safe and lightweight — can be made a constant
Gson gson = new Gson();

// Model — Gson doesn't need annotations for simple cases
public class Product {
    private Long id;
    private String name;
    private double price;

    // Gson works with fields directly, doesn't need getters/setters
    public Product(Long id, String name, double price) {
        this.id = id; this.name = name; this.price = price;
    }
}

Product laptop = new Product(1L, "Laptop", 12_000_000);

// Serialization
String json = gson.toJson(laptop);
// {"id":1,"name":"Laptop","price":1.2E7}

// With GsonBuilder for configuration
Gson prettyGson = new GsonBuilder()
    .setPrettyPrinting()                   // pretty print
    .serializeNulls()                      // include null fields
    .disableHtmlEscaping()                 // don't escape HTML characters
    .setDateFormat("yyyy-MM-dd'T'HH:mm:ss") // custom date format
    .create();

// Deserialization
Product fromJson = gson.fromJson(json, Product.class);
System.out.println(fromJson.name); // Laptop

// List — needs a TypeToken
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;

String jsonList = "[{\"id\":1,\"name\":\"Laptop\"},{\"id\":2,\"name\":\"Mouse\"}]";
Type listType = new TypeToken<List<Product>>(){}.getType();
List<Product> list = gson.fromJson(jsonList, listType);

// Map
Type mapType = new TypeToken<Map<String, Product>>(){}.getType();
Map<String, Product> map = gson.fromJson(jsonMap, mapType);

Gson Customization #

import com.google.gson.*;

// Custom serializer — convert LocalDate into a string
Gson gson = new GsonBuilder()
    .registerTypeAdapter(LocalDate.class, (JsonSerializer<LocalDate>)
        (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
    .registerTypeAdapter(LocalDate.class, (JsonDeserializer<LocalDate>)
        (json, typeOfT, ctx) -> LocalDate.parse(json.getAsString()))
    .create();

// @SerializedName: equivalent to Jackson's @JsonProperty
public class User {
    @com.google.gson.annotations.SerializedName("full_name")
    private String fullName;

    @com.google.gson.annotations.Expose // only include fields marked @Expose
    private String email;

    private transient String passwordHash; // 'transient' excludes fields from Gson
}

Jackson vs Gson — Comparison #

AspectJacksonGson
PerformanceFasterSlightly slower
FeaturesVery completeMore limited
ConfigurationMore optionsSimpler
Annotations@JsonProperty, @JsonIgnore, etc.@SerializedName, transient
Spring BootDefault, fully integratedNeeds manual configuration
Generic typesTypeReferenceTypeToken
JAR sizeLargerSmaller
java.time supportJSR-310 moduleNeeds manual adapters
PopularityHigher in enterpriseHigher on Android

Spring Boot Integration #

Spring Boot uses Jackson automatically for all @RestController responses. You can configure it via application.properties or an ObjectMapper bean.

Configuration via application.properties #

# Default date format
spring.jackson.date-format=yyyy-MM-dd'T'HH:mm:ss
spring.jackson.time-zone=Asia/Jakarta

# Don't write dates as timestamps
spring.jackson.serialization.write-dates-as-timestamps=false

# Ignore unknown fields during deserialization
spring.jackson.deserialization.fail-on-unknown-properties=false

# Pretty print (don't enable in production — wastes bandwidth)
spring.jackson.serialization.indent-output=false

# Don't include null fields
spring.jackson.default-property-inclusion=NON_NULL

# Naming strategy: snake_case for all fields
spring.jackson.property-naming-strategy=SNAKE_CASE

Configuration via a Bean #

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
    }
}

Requests and Responses in Controllers #

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // Spring automatically deserializes the JSON body into Product
    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product product) {
        // product is already deserialized from the JSON request body
        Product saved = service.save(product);
        return ResponseEntity.status(201).body(saved);
        // Spring automatically serializes saved into the JSON response body
    }

    // Responses can be Maps for ad-hoc JSON
    @GetMapping("/info")
    public Map<String, Object> info() {
        return Map.of(
            "version", "1.0.0",
            "time", LocalDateTime.now(),
            "status", "active"
        );
    }
}

Real-World Cases #

Calling an External REST API #

import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

HttpClient client = HttpClient.newHttpClient();

// GET — receive JSON and parse
HttpRequest getReq = HttpRequest.newBuilder()
    .uri(java.net.URI.create("https://api.example.com/products/1"))
    .header("Accept", "application/json")
    .GET()
    .build();

HttpResponse<String> res = client.send(getReq, HttpResponse.BodyHandlers.ofString());
Product product = mapper.readValue(res.body(), Product.class);

// POST — send an object as JSON
Product newProduct = new Product(null, "Monitor", 3_500_000, 10);
String requestBody = mapper.writeValueAsString(newProduct);

HttpRequest postReq = HttpRequest.newBuilder()
    .uri(java.net.URI.create("https://api.example.com/products"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(requestBody))
    .build();

HttpResponse<String> postRes = client.send(postReq, HttpResponse.BodyHandlers.ofString());
Product saved = mapper.readValue(postRes.body(), Product.class);

Reading and Writing JSON Files #

// Read from a file
List<Product> catalog = mapper.readValue(
    new java.io.File("catalog.json"),
    new TypeReference<List<Product>>() {}
);

// Write to a file with pretty print
mapper.writerWithDefaultPrettyPrinter()
    .writeValue(new java.io.File("catalog-new.json"), catalog);

// Read from the classpath (resource file inside a JAR)
InputStream stream = getClass().getResourceAsStream("/data/products.json");
Product fromResource = mapper.readValue(stream, Product.class);

Transforming JSON without a Model #

// Convert all field names to snake_case without defining a target class
String camelJson = "{\"fullName\":\"Budi\",\"birthDate\":\"1995-01-15\"}";

JsonNode node = mapper.readTree(camelJson);
// process and transform nodes as needed
ObjectNode transformed = mapper.createObjectNode();
node.fields().forEachRemaining(e -> {
    // convert camelCase to snake_case
    String snakeKey = e.getKey().replaceAll("([A-Z])", "_$1").toLowerCase();
    transformed.set(snakeKey, e.getValue());
});

String snakeJson = mapper.writeValueAsString(transformed);
// {"full_name":"Budi","birth_date":"1995-01-15"}

When to Use Jackson vs Gson #

Use JACKSON when:
  ✓ Spring Boot projects (already present, no extra dependencies needed)
  ✓ You need full features: custom serializers, JsonNode, streaming API
  ✓ You need smooth java.time support (with the JSR-310 module)
  ✓ You need a global naming strategy (snake_case, kebab-case)
  ✓ Enterprise applications with complex JSON needs

Use GSON when:
  ✓ Android projects or lightweight non-Spring projects
  ✓ You don't need advanced features — simple serialization/deserialization
  ✓ You want minimal setup without configuration
  ✓ The team is already familiar with Gson

Anti-patterns to avoid:
  ✗ Creating a new ObjectMapper for every request — very expensive, make it a singleton
  ✗ Ignoring unknown properties without a reason — can hide bugs
  ✗ Storing passwords or sensitive data in JSON responses without @JsonIgnore
  ✗ Using Jackson and Gson in the same project without a clear reason

Summary #

  • ObjectMapper is a singleton — it’s thread-safe and expensive to create. In Spring Boot, let the framework manage it. In plain code, create it once as a static constant.
  • writeValueAsString() for serialization, readValue() for deserialization — the two main methods used most often. Add writerWithDefaultPrettyPrinter() for tidy output.
  • TypeReference for generic typesmapper.readValue(json, new TypeReference<List<Product>>(){}) because Java erases generic information at runtime.
  • @JsonProperty for custom naming, @JsonIgnore for excluded fields, @JsonInclude(NON_NULL) to skip nulls — the three annotations you’ll need most often.
  • Register JavaTimeModule for java.time — without it, LocalDate and LocalDateTime are serialized as number arrays, not readable ISO-8601 strings.
  • JsonNode for JSON with dynamic structures — when the JSON structure is unknown or varies, navigate with root.get("field"), root.at("/nested/path"), and root.has("field").
  • @JsonIgnoreProperties(ignoreUnknown = true) or FAIL_ON_UNKNOWN_PROPERTIES = false — important for forward compatibility when APIs add new fields not yet in your model.
  • Gson is lighter, Jackson is more complete — for Spring Boot projects choose Jackson; for Android or lightweight projects without Spring, Gson is a good choice.

← Previous: Stream   Next: YAML →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact