JSON and Jackson

Every getter is a property, unknown and missing fields, java.time without its module, and the id JavaScript rounded to a different record.

6 min read🧰 Exceptions, I/O and Reflection

Almost every Java service turns objects into JSON and back, many thousands of times a second, and almost nobody writes that code. Jackson does it. Spring calls it on every request body and every response, and it is so reliable that people forget it is making decisions — about which fields exist, what a missing value means, and how a number is written.

This lesson is about those decisions, because each one has a default, and several of the defaults are how data leaks or quietly changes.

All the output below is from Jackson 2.18.2 on a JVM, with a plain new ObjectMapper() unless it says otherwise.

What Jackson looks at

Jackson does not read your fields directly. By default it builds a property for every public getter, and for every public field:

User.javajava
public static class User {
    private long id;
    private String email;
    private String passwordHash;
    public User() {}
    public User(long id, String email, String passwordHash) { /* assigns all three */ }
    public long getId() { return id; }
    public String getEmail() { return email; }
    public String getPasswordHash() { return passwordHash; }
}
plaintext
{"id":9007199254740993,"email":"ana@example.com","passwordHash":"$2a$10$abc"}

The password hash is in the response. Nobody decided to publish it. Somebody added a getter because some other code needed it, and a getter is a decision about your JSON whether you meant it to be or not.

There are two fixes, and the second is the one that lasts:

SafeUser.javajava
public static class SafeUser {
    // ...the same fields and constructor
    public long getId() { return id; }
    public String getEmail() { return email; }
    @JsonIgnore public String getPasswordHash() { return passwordHash; }
}
plaintext
{"id":42,"email":"ana@example.com"}

@JsonIgnore works, and it is a blocklist: the next sensitive field someone adds is published until somebody remembers the annotation. The durable fix is not to serialize the entity at all. Map it to a response class that contains only what the API promises — the REST course's lesson on DTOs makes that case in full. Then a new field on the entity changes nothing on the wire until someone adds it to the DTO on purpose.

Unknown fields: strict or lenient

A client sends a field your class does not have:

plaintext
{"sku":"A1","quantity":2,"coupon":"FREE"}

A plain ObjectMapper refuses it:

plaintext
UnrecognizedPropertyException: Unrecognized field "coupon" (class JsonDemo$Order), not marked as ignorable (2 known properties: "quantity", "sku"])

Turn that off with FAIL_ON_UNKNOWN_PROPERTIES = false and the same input reads as sku=A1 quantity=2, with the coupon silently dropped.

Spring Boot's auto-configured mapper turns it off for you. That is the right default for most APIs — it lets a client send a newer version of a request to an older server, and lets you add fields to responses without breaking old clients — and it has a cost worth knowing: a client that misspells a field name (quantitiy) gets no error. The value is simply ignored, and the request succeeds with the default.

Missing fields are not errors

Which leads to the more dangerous case:

plaintext
input:  {"sku":"A1"}
result: quantity=0

No exception. A missing int becomes 0, a missing boolean becomes false, a missing object becomes null. Jackson cannot distinguish "the client sent zero" from "the client sent nothing", and for an order quantity those mean very different things.

Immutable classes need to tell Jackson how

A class with only a constructor and final fields:

java
public static class Immutable {
    private final String name;
    public Immutable(String name) { this.name = name; }
    public String getName() { return name; }
}
plaintext
MismatchedInputException: Cannot construct instance of `JsonDemo$Immutable` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)

Jackson's default is: call the no-argument constructor, then set properties. With no such constructor, it needs to be told which constructor parameter is which JSON property — @JsonCreator with @JsonProperty("name") on the parameter, or compiling with -parameters and registering the parameter-names module, which Spring Boot does. Java records are handled directly by Jackson 2.12 and later.

Dates, and the module you did not add

plaintext
InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling

A plain ObjectMapper cannot write an Instant. The error is admirably clear, and it is the reason for the most common Jackson bug report: code that works inside Spring, where the module is registered for you, fails in a unit test or a batch job that creates its own new ObjectMapper().

With the module, decide the format deliberately. Write instants as ISO-8601 strings in UTC ("2026-09-13T10:00:00Z"): readable, sortable as text, and unambiguous. Numeric timestamps save a few bytes and invite the milliseconds-or-seconds argument with every client.

Numbers that change on the way through

Two runs, two surprises. First, money:

plaintext
{"asDouble":0.30000000000000004,"asDecimal":0.3}

0.1 + 0.2 as a double is not 0.3, and Jackson writes exactly what the double holds. Keep money in BigDecimal, or in integer minor units (paise, cents) as a long.

Second, and far less known — the User above had the id 9007199254740993. Jackson wrote it correctly. Here is what a JavaScript client does with that response:

plaintext
> JSON.parse('{"id":9007199254740993,"email":"ana@example.com"}').id
9007199254740992

The id changed by one. JSON numbers have no size limit, but JavaScript reads every number as a 64-bit double, which represents integers exactly only up to 2^53 − 1 (9,007,199,254,740,991). A Java long goes far beyond that. Snowflake-style ids and database sequences past that range are silently rounded in every browser, and the client then asks for, updates or deletes a different record.

One ObjectMapper, shared

An ObjectMapper is expensive to create and cheap to use: it caches what it learns about each class. It is thread-safe once configured, so an application should create one and share it — which is what injecting Spring's does.

Serializing the same small object 20,000 times, twice:

plaintext
round 0: new mapper each call 421 ms, shared mapper 26 ms (20,000 calls)
round 1: new mapper each call 197 ms, shared mapper 5 ms (20,000 calls)

Forty times slower per call on the warm round, and the first round includes JIT warm-up for both. A new ObjectMapper() inside a request handler is a slow leak of CPU that shows up nowhere except in a flame graph. The exact ratio is one machine's; the direction is not.

The one rule that goes with sharing: configure it before you share it. Changing a shared mapper's settings while other threads are using it is not supported, and copy() exists for the case where one caller needs different settings.

Why not Java serialization

Java has its own built-in object serialization (Serializable, ObjectInputStream). It is not an alternative to JSON for anything that crosses a trust boundary. Deserializing a stream can instantiate arbitrary classes on the classpath and run code in them, and it has been the root of remote code execution vulnerabilities in widely used libraries. JSON mapped onto classes you chose is a much smaller attack surface.

Jackson has its own version of that trap: polymorphic typing from input, where the JSON names the class to instantiate. Enabling it globally (the old enableDefaultTyping) produced a long series of CVEs for the same reason. If a type hierarchy must be deserialized, use @JsonTypeInfo with an explicit, closed list of subtypes.

Progress is saved on this device and to your account when signed in.