Jackson From First Principles: What ObjectMapper Does, What It Doesn’t, and When to Write a Custom Serializer

I’ve used ObjectMapper for years without really understanding what it does for free, what it refuses to do, and when you actually need to step in with a custom serializer.

The recipe — mapper.readValue(json, MyClass.class) — works so well, most of the time, that I’d never sat down with the documentation. Then I had to write a custom deserializer, and realised I didn’t understand half of what I was working around.

What follows is the answer I worked through, in the order any developer using Spring Boot and Jackson should ask the questions. Eleven questions, building on each other. By the end, the “Jackson is magic” feeling should be largely gone.


1. What is ObjectMapper, actually?

It’s a stateful, configurable JSON-to-Java mapper. One object you instantiate (or, in Spring Boot, one Spring builds and injects for you), you call readValue to deserialise and writeValueAsString (or writeValueAsBytes) to serialise, and the same instance handles both directions.

Two things are worth naming up-front, because they shape every later question:

  • It sits on top of a separate, lower-level layer: Jackson’s “streaming API” (the package com.fasterxml.jackson.core). That layer doesn’t know about your Java classes at all — it just emits tokens ({, }, "field", 42). ObjectMapper is the “data binding” layer that turns those tokens into Java objects and back.
  • It’s stateful but thread-safe once configured. You’re supposed to build one, configure it (modules, features, mix-ins), and reuse it. Reconfiguring after first use is not safe; building a fresh one per call is wasteful. Spring Boot does the right thing: one shared, pre-configured instance, autowired wherever you need it.

That’s the whole “what is it” — a stateful, threadsafe, configurable wrapper over a streaming JSON parser/generator, that knows how to map tokens to your classes.


2. How does it actually parse JSON?

This is the mental model that unlocks every later question.

When you call mapper.readValue(jsonBytes, MyClass.class), Jackson does not load the whole JSON into memory and traverse it. It opens a streaming parser that emits a sequence of tokens, in order, one at a time:

{                       →  START_OBJECT
  "userId": 42,         →  FIELD_NAME "userId"  +  VALUE_NUMBER_INT 42
  "name": "Alice",      →  FIELD_NAME "name"    +  VALUE_STRING "Alice"
  "tags": ["x", "y"]    →  FIELD_NAME "tags"
                        →  START_ARRAY
                        →  VALUE_STRING "x"
                        →  VALUE_STRING "y"
                        →  END_ARRAY
}                       →  END_OBJECT

A deserializer is, mechanically, a function that says: “given a parser positioned at the next token, consume some number of tokens and produce one Java value.” The default deserializer for MyClass knows: “I expect START_OBJECT, then for each FIELD_NAME match it to a field on MyClass, recursively deserialize the value, then expect END_OBJECT.”

This streaming model is why Jackson can parse files larger than memory, why it’s fast, and — crucially for the rest of this article — why custom deserializers are short and local: each one only has to know how to handle one type’s tokens, not the whole document. You’re never reimplementing JSON parsing; you’re just deciding what to do with the next N tokens.

The single sentence to internalise: Jackson moves through a JSON stream as a sequence of tokens, and a “deserializer” is a function that knows how to turn the next N tokens into one Java value.


3. Are serialisation and deserialisation symmetric?

In intent, yes. In code path, no — and the asymmetry is the source of half of all Jackson confusion.

Different classes, different annotations, different config:

Read (JSON → Java)Write (Java → JSON)
Override classJsonDeserializer<T>JsonSerializer<T>
Field-level annotation@JsonDeserialize(using = ...)@JsonSerialize(using = ...)
Mapper-level feature enumDeserializationFeatureSerializationFeature
Per-class annotation@JsonDeserialize@JsonSerialize, @JsonInclude

A custom deserializer does not automatically give you a custom serializer. If a class needs both — say, you want a LocalDate written as "2026-06-17" and read back from the same — you write two small classes (or use one of the prebuilt modules that ships both directions for common types).

And the round-trip doesn’t always work, even with defaults. Three quiet asymmetries to know:

  • Map<EnumKey, V>: serialises to a JSON object keyed by the enum’s name() (e.g., {"NORTH": ..., "SOUTH": ...}). Deserialisation back needs a KeyDeserializer for the enum because JSON keys are always strings. Round-trip can work, but it requires that the enum’s name() and Jackson’s enum lookup agree — easy to break if you ever rename a constant or add a @JsonValue.
  • BigDecimal precision: by default Jackson reads decimals as Double unless told otherwise, which silently loses precision. The opt-in is DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS. Spring Boot does not flip this; you have to.
  • null vs missing field: serialising null writes "field": null; deserialising both "field": null and a missing field produces null on the Java side. So you can’t tell, after a read, whether the producer wrote null or simply didn’t include the field. If that distinction matters, you need Optional<T> fields and JsonInclude.Include.NON_ABSENT.

Asymmetry isn’t a bug — it’s a consequence of JSON being a write-only-friendly format. Two pieces of advice fall out: don’t assume mapper.readValue(mapper.writeValueAsString(x)) equals x, and write a test for any round-trip you actually rely on.


4. What does it handle for me, exactly?

This is the table I wish I’d had pinned to a wall for the first three years.

JSONJava targetOut of the box?
{ ... } (object)POJO with matching fields
{ ... }Map<String, V>
[ ... ] (array)List<T> / Set<T> / Collection<T> / T[]
"text"String, enum (by name), UUID, URI, URL
42 / 4.2numeric primitives, BigDecimal, BigInteger✅ (precision caveat per §3)
true / falseboolean / Boolean
nullnull or wrapper’s null state
"2026-06-17"LocalDate, LocalDateTime, Instant, OffsetDateTime⚠️ Only with JavaTimeModule (jackson-datatype-jsr310)
omittedOptional<T> (empty)⚠️ Only with Jdk8Module (jackson-datatype-jdk8)
anythingKotlin data classes’ default values⚠️ Only with KotlinModule (jackson-module-kotlin)
42Long, even if no L suffix in JSON
"42"Integer❌ unless MapperFeature.ALLOW_COERCION_OF_SCALARS

The three ⚠️ rows are the trap. Raw Jackson — new ObjectMapper() — does not know how to parse a LocalDate. The Java time module ships in a separate artifact you have to add to your dependencies and register on the mapper. Spring Boot registers it for you by default (more on this in §6), which is why your prod code works and your “let me just new ObjectMapper() for this test” suddenly throws on a LocalDate field.


5. And what does it refuse to do?

The list of refusals is shorter and arguably more useful to know. Jackson will not coerce across these JSON shapes unless you opt in:

You haveYou wantDefaultOpt-in
[] (empty array)Map<K,V> or POJOthrows MismatchedInputExceptionACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT (maps to null)
"" (empty string)object / numberthrowsACCEPT_EMPTY_STRING_AS_NULL_OBJECT
"x" (single scalar)List<String>throwsACCEPT_SINGLE_VALUE_AS_ARRAY (wraps as ["x"])
42 (number)StringthrowsALLOW_COERCION_OF_SCALARS
"42" (string)intthrowssame
unknown JSON fieldPOJO without that fieldthrows in raw JacksonFAIL_ON_UNKNOWN_PROPERTIES = false (Spring Boot does this for you)
array of mixed-shape objectsa single Java typethrowscustom deserializer that branches on token content

The principle is simple and worth tattooing: Jackson coerces nothing across structural JSON types unless you opt in. [] is not {}. "" is not null. "42" is not 42. The structure has to match — or you have to ask explicitly for it to be loose.

The companion war story covers what happens when this principle catches you in production (a PHP-backed API returning [] instead of {} for an empty map, amplified by @Retryable + N+1 into a 30-second Heroku timeout). If you haven’t read it: that’s the “why this matters” piece. This post is the “why this is the way it is” piece.


6. Why does my Spring Boot project parse stuff that raw Jackson can’t?

Because the ObjectMapper Spring Boot autowires into your service is not new ObjectMapper(). It’s built by Jackson2ObjectMapperBuilder, which applies a long list of defaults you didn’t write:

  • FAIL_ON_UNKNOWN_PROPERTIES = false — your service won’t crash when a vendor adds a field
  • WRITE_DATES_AS_TIMESTAMPS = falseLocalDate serialises to "2026-06-17", not to 19891
  • Registers JavaTimeModule (jsr310) — LocalDate / Instant etc. parse and emit naturally
  • Registers Jdk8ModuleOptional<T> works without manual wiring
  • Registers ParameterNamesModule — lets Jackson infer constructor parameter names without @JsonProperty on every arg
  • Picks up any @Configuration class with Jackson2ObjectMapperBuilderCustomizer you’ve defined

The practical consequence: new ObjectMapper() in a test will behave differently from the @Autowired one in your service. Same library, different defaults. This is one of the most common sources of “works in prod, fails in test” — a unit test instantiates a fresh mapper and discovers that LocalDate no longer parses, or that an unknown field now throws.

The fix is one of two things:

  1. Inject the configured mapper into your test — use @Autowired in a @SpringBootTest (heavy), or @JsonTest (lightweight, mapper-only context).

  2. Build a test mapper that mirrors prod — explicitly register the modules and set the features you actually use:

    var mapper = new ObjectMapper()
        .registerModule(new JavaTimeModule())
        .registerModule(new Jdk8Module())
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    

The second option keeps the unit test fast and explicit. Just be aware that “explicit” means you are responsible for keeping it in sync with whatever Spring Boot does — if you upgrade Spring Boot and they flip another feature, your test mapper doesn’t follow.

If a JSON test passes locally and fails in CI (or vice versa), 80% of the time the answer is “the two test runs are using different ObjectMappers.” Print the mapper config in the failing test before you go anywhere else.


7. Why does mapper.readValue(json, Map.class) lose my generic type?

Because Java erases generic type parameters at runtime. The Class<?> you can hold for a Map<String, BigDecimal> is just Map.class. There’s no Map<String, BigDecimal>.class at the bytecode level — the <String, BigDecimal> part is erased after compilation. The information is gone before Jackson ever sees it.

So when you call:

Map<String, BigDecimal> rates = mapper.readValue(json, Map.class);

…Jackson reads the JSON object correctly, but it doesn’t know that the values should be BigDecimal — only that the result is “some map.” The values arrive as Double (Jackson’s default for JSON numbers), and the @SuppressWarnings("unchecked") you needed to write that line was hiding a real bug: the runtime type of the map’s values is Double, not BigDecimal, and a downstream BigDecimal operation will throw ClassCastException.

The fix is the TypeReference idiom — an anonymous subclass that preserves the generic argument in its class metadata, where reflection can still see it:

Map<String, BigDecimal> rates =
    mapper.readValue(json, new TypeReference<Map<String, BigDecimal>>() {});

What’s happening: new TypeReference<Map<String, BigDecimal>>() {} creates an anonymous subclass of TypeReference, parameterised with the generic type. The subclass’s Class.getGenericSuperclass() returns the parameterised TypeReference<Map<String, BigDecimal>> — and the <...> part survives there because Java erases type variables, not parameterised types declared in class hierarchies. The empty {} braces matter: without them you’re just instantiating the abstract TypeReference, which Jackson can’t call methods on.

This is the single Java idiom every serialisation library has to work around, and you’ll see the same TypeReference pattern (or its equivalents — Gson has TypeToken, Spring has ParameterizedTypeReference) across the entire ecosystem.

Rule of thumb: any time the type you want has angle brackets in it, use TypeReference. The compiler doesn’t warn you when you skip it; the runtime cast is what eventually does.


8. How do I deserialise polymorphic types — an interface or a sealed hierarchy?

This is where Jackson stops being magic and starts requiring explicit declarations from you. The reason: by default, Jackson serialises an object to a JSON object with no hint of which Java type it came from. When you deserialise back, there’s no information in the JSON saying “this was originally a CashPayment, not a CardPayment.”

So if you have:

sealed interface Payment permits CashPayment, CardPayment { ... }
record CashPayment(BigDecimal amount, String tendered) implements Payment {}
record CardPayment(BigDecimal amount, String last4) implements Payment {}

…and you do mapper.readValue(json, Payment.class), Jackson will throw because it can’t choose between the two subtypes. You have to tell it how.

There are two standard strategies:

Strategy A — “property” (the common one)

A type discriminator field lives inside the JSON object:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = CashPayment.class, name = "cash"),
    @JsonSubTypes.Type(value = CardPayment.class, name = "card"),
})
sealed interface Payment permits CashPayment, CardPayment { ... }
{
  "type": "cash",
  "amount": 12.50,
  "tendered": "20.00"
}

Pros: the JSON shape is clean and natural; “type” lives where the data lives. Cons: you have to control the JSON shape (you can’t use this against a third-party API that doesn’t include a type field).

Strategy B — “wrapper-object” (the explicit one)

The JSON wraps the value in an envelope keyed by the type name:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
@JsonSubTypes({ ... same as above ... })
{
  "cash": {
    "amount": 12.50,
    "tendered": "20.00"
  }
}

Pros: unambiguous, doesn’t require modifying the inner object’s shape. Cons: verbose; non-standard if you’re publishing the API.

Which to use: if you own both ends of the JSON contract, Strategy A — it’s the de facto standard for JSON polymorphism in the Java/Jackson world. If you’re forced into a wrapper shape by a contract that already exists (older XML-derived APIs sometimes look like this), Strategy B. If you can’t make either work because the discriminator depends on the shape of the object (e.g., “if the object has a last4 field it’s a card, otherwise cash”), you’ll need a custom deserializer that branches on the token content — covered in §9.


9. When should I write a custom deserializer?

Three cases, in increasing order of how common they are:

  1. The data shape doesn’t match Jackson’s expectations. The empty-array-into-map case from the companion war story is the canonical example. Third-party APIs serialise things in shapes Jackson refuses by default, and the simplest fix is a custom deserializer that handles the specific anomaly.
  2. You need to read multiple JSON paths into one Java object. A response with "data": {...}, "meta": {...}, "_links": {...} where you want all three flattened into one POJO. Default field-name matching can’t do that; a custom deserializer can use parser.readValueAsTree() to read the whole object, then pick fields out.
  3. You need conditional logic during parsing. The “discriminate by shape” case from the end of §8: peek at the next token, decide which concrete type to deserialise into, delegate accordingly.

The three-step recipe

Every custom deserializer follows the same shape — short, local, never reimplementing parsing:

public class FooDeserializer extends JsonDeserializer<Foo> {

    @Override
    public Foo deserialize(JsonParser parser, DeserializationContext ctxt)
            throws IOException {

        // Step 1 — inspect the current token to detect the anomaly
        if (parser.currentToken() == JsonToken.START_ARRAY) {
            parser.skipChildren();
            return Foo.empty();   // handle the anomaly
        }

        // Step 2 — delegate to Jackson for the normal case
        return parser.readValueAs(Foo.class);
    }
}
// Wire it on the field (preferred) or the class
@JsonDeserialize(using = FooDeserializer.class)
private Foo foo;

Three things make this pattern robust:

  • parser.currentToken() is the right hook. When deserialize is called, the parser is positioned at the first token of the value (START_OBJECT, START_ARRAY, VALUE_STRING, etc.). Branch on that.
  • parser.readValueAs(Foo.class) is the delegation. You’re handing the parser back to Jackson for the normal case. You are not writing a parse loop yourself.
  • Annotate the field, not the global mapper. @JsonDeserialize(using = ...) on the field means the deserializer runs regardless of which ObjectMapper parses this class — including ad-hoc ones in tests.

Three common antipatterns

The most common mistakes I’ve seen in code review (or written myself):

  • Reimplementing the loop. Calling parser.nextToken() in a while (END_OBJECT) loop and matching field names by hand. Don’t. Delegate to readValueAs. The only time you need to step through tokens yourself is when the shape genuinely doesn’t match any Java type — and even then, readValueAsTree() into a JsonNode is usually cleaner.
  • Returning null instead of empty. A deserializer that returns null for the anomalous case pushes an NPE risk into every downstream caller. Return Foo.empty(), Map.of(), List.of(), Optional.empty() — whatever the empty form of your type is. Future callers won’t have to defensively null-check.
  • Hand-rolling type checks instead of delegating to context. When you genuinely need to reject a malformed token, call ctxt.handleUnexpectedToken(...) or throw MismatchedInputException via ctxt.reportInputMismatch(...). Both produce error messages that Jackson can decorate with parser position and JSON path — far more useful in logs than your own IllegalArgumentException.

A custom deserializer is short. If yours is more than ~20 lines, you’re almost certainly doing parsing work that Jackson would do for you. Try delegating, and only step in for the specific anomalous token your code actually has to handle.


10. And a custom serializer?

Less common but follows the same shape. Three cases that warrant one:

  1. You want a Java object to serialise in a non-default JSON shape. A Money record that has amount and currency fields but should serialise as "12.50 USD".
  2. You need to redact fields conditionally. A User whose email is included only when a context flag is set.
  3. You’re emitting a format the consumer expects that doesn’t match any default mapping — flattened envelopes, computed totals, denormalised aggregates.

The recipe:

public class MoneySerializer extends JsonSerializer<Money> {

    @Override
    public void serialize(Money value, JsonGenerator gen, SerializerProvider serializers)
            throws IOException {

        if (value == null) {
            gen.writeNull();                            // always handle null explicitly
            return;
        }

        gen.writeString(value.amount() + " " + value.currency());
    }
}
@JsonSerialize(using = MoneySerializer.class)
private Money price;

Three pitfalls worth naming, because they’re easy to miss:

  • Forgetting to handle null — by default Jackson skips fields whose value is null and never calls your serializer at all, but if @JsonInclude(Include.ALWAYS) is set anywhere up the chain (class-level, mapper-level), your serialize will be called with null and crash on value.amount(). Always start with the null check.
  • Breaking pretty-printing — if you write multiple tokens (e.g., a full nested object), use gen.writeStartObject() / gen.writeStringField(...) / gen.writeEndObject(), not concatenated string output. Otherwise pretty-printing produces broken indentation.
  • Forgetting @JsonInclude compatibility — if your custom serializer never emits a value for “empty” cases (e.g., zero Money), Jackson’s @JsonInclude(Include.NON_EMPTY) won’t catch that automatically. You need to either emit null so the include filter sees it, or define your type’s “empty” with @JsonInclude.Include.CUSTOM + a custom filter.

11. Where should I configure all this — globally, per-class, or per-field?

Three layers, three blast radii. The general rule: scope as narrowly as the requirement actually allows.

LayerWhere it livesBlast radiusUse when
GlobalObjectMapper.configure(...), Jackson2ObjectMapperBuilderCustomizerEvery JSON read/write in your appYou genuinely want it everywhere (e.g., FAIL_ON_UNKNOWN_PROPERTIES = false)
Per-class@JsonInclude, @JsonDeserialize, @JsonSerialize on the classEvery field of one classClass-wide rule with no exceptions (e.g., all fields non-null in serialisation)
Per-fieldSame annotations on the fieldJust that fieldYou only want the rule for this one place

The two failure modes are mirror images of each other:

  • Scope too wide: a global flag flips behaviour for code you’ve never seen. Six months later, a teammate writes a class that depends on the default behaviour, and is mysteriously bitten by your global. Especially dangerous with ACCEPT_SINGLE_VALUE_AS_ARRAY and ALLOW_COERCION_OF_SCALARS — both make your mapper more permissive in ways that cascade silently.
  • Scope too narrow: you write the same annotation on twenty fields across ten classes. The cost is just maintenance, not correctness — and it’s usually a sign you should have gone class-wide or built a small @interface wrapper.

Rule of thumb: start per-field, expand to per-class when you have ≥3 fields that need the same rule, expand to global only when the requirement is genuinely cross-cutting. Global flags are the most powerful and the easiest to regret.


Pulling it all back together

What a working mental model of Jackson looks like, after all this:

ObjectMapper is a stateful, configurable wrapper over a streaming token parser. It reads JSON as a sequence of tokens and emits Java values via deserializers — short, local functions that know “given the next N tokens, produce one value.” Writing JSON is the symmetric path: walk the Java object, ask each serializer to emit tokens, hand them to a generator that produces bytes. The two paths use different classes and don’t always round-trip, especially around enum keys, decimal precision, and the null-vs-missing distinction.

The defaults you get depend on which modules are registered. Raw Jackson handles primitives and collections; JavaTimeModule adds LocalDate and friends; Jdk8Module adds Optional; KotlinModule adds Kotlin data class niceties. Spring Boot pre-registers the first three and flips a handful of feature flags (FAIL_ON_UNKNOWN_PROPERTIES = false, dates as ISO strings). Your @Autowired ObjectMapper is not new ObjectMapper().

Generic types lose their type arguments at runtime (Java erasure), so any generic target needs new TypeReference<Map<...>>() {} — the anonymous subclass trick preserves the parameterisation in the class hierarchy where reflection can read it. Polymorphic deserialisation needs explicit @JsonTypeInfo and @JsonSubTypes; without them, Jackson has no information to pick a concrete subtype.

When the defaults don’t fit, custom (de)serializers are short and surgical: extend the base class, branch on the current token (read) or check for null (write), delegate to Jackson for the rest. Annotate the field, not the global mapper. Return empty, not null. If your custom code is more than ~20 lines, you’re probably doing work Jackson would do for you.

That mental model — token streams, modules, type erasure, polymorphism annotations, narrow custom hooks — is roughly the whole library. There’s vastly more API surface (mix-ins, polymorphic deduction, tree models, JsonViews) but every advanced corner is built on those same five concepts. “Jackson is magic” should now mean “Jackson is just a token parser with a lot of opt-in behaviour.”

Related Posts

save, saveAndFlush, and REQUIRES_NEW: The Hibernate Questions Hiding in One Spring Service Method

Last week I was reading a teammate’s PR and stopped on a method that made me realise I’d been writing Spring + Hibernate code for years without actually understanding what save, flush, commit, and REQUIRES_NEW each do.

Read more

A Customer Couldn’t Upload an Invoice. The Fix Re-Taught Me XSD, JAXB, and Maven.

A Polish customer reports their invoice won’t upload.

The file looks valid. Other Polish customers upload fine.

Three days later I’ve read more of the Polish Tax Code than I’d like to admit.

Read more

What Jackson Doesn’t Deserialize for Free: The Empty Array That Took Down a Production Endpoint

A 503. Every 30 seconds, on the dot.

Sentry was empty. The application logs said the request was fine.

Then we looked at what Jackson was actually parsing.

Read more