Jackson From First Principles: What ObjectMapper Does, What It Doesn’t, and When to Write a Custom Serializer
Table of Contents
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).ObjectMapperis 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 class | JsonDeserializer<T> | JsonSerializer<T> |
| Field-level annotation | @JsonDeserialize(using = ...) | @JsonSerialize(using = ...) |
| Mapper-level feature enum | DeserializationFeature | SerializationFeature |
| 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’sname()(e.g.,{"NORTH": ..., "SOUTH": ...}). Deserialisation back needs aKeyDeserializerfor the enum because JSON keys are always strings. Round-trip can work, but it requires that the enum’sname()and Jackson’s enum lookup agree — easy to break if you ever rename a constant or add a@JsonValue.BigDecimalprecision: by default Jackson reads decimals asDoubleunless told otherwise, which silently loses precision. The opt-in isDeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS. Spring Boot does not flip this; you have to.nullvs missing field: serialisingnullwrites"field": null; deserialising both"field": nulland a missing field producesnullon the Java side. So you can’t tell, after a read, whether the producer wrotenullor simply didn’t include the field. If that distinction matters, you needOptional<T>fields andJsonInclude.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))equalsx, 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.
| JSON | Java target | Out 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.2 | numeric primitives, BigDecimal, BigInteger | ✅ (precision caveat per §3) |
true / false | boolean / Boolean | ✅ |
null | null or wrapper’s null state | ✅ |
"2026-06-17" | LocalDate, LocalDateTime, Instant, OffsetDateTime | ⚠️ Only with JavaTimeModule (jackson-datatype-jsr310) |
| omitted | Optional<T> (empty) | ⚠️ Only with Jdk8Module (jackson-datatype-jdk8) |
| anything | Kotlin data classes’ default values | ⚠️ Only with KotlinModule (jackson-module-kotlin) |
42 | Long, 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 have | You want | Default | Opt-in |
|---|---|---|---|
[] (empty array) | Map<K,V> or POJO | throws MismatchedInputException | ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT (maps to null) |
"" (empty string) | object / number | throws | ACCEPT_EMPTY_STRING_AS_NULL_OBJECT |
"x" (single scalar) | List<String> | throws | ACCEPT_SINGLE_VALUE_AS_ARRAY (wraps as ["x"]) |
42 (number) | String | throws | ALLOW_COERCION_OF_SCALARS |
"42" (string) | int | throws | same |
| unknown JSON field | POJO without that field | throws in raw Jackson | FAIL_ON_UNKNOWN_PROPERTIES = false (Spring Boot does this for you) |
| array of mixed-shape objects | a single Java type | throws | custom 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 fieldWRITE_DATES_AS_TIMESTAMPS = false—LocalDateserialises to"2026-06-17", not to19891- Registers
JavaTimeModule(jsr310) —LocalDate/Instantetc. parse and emit naturally - Registers
Jdk8Module—Optional<T>works without manual wiring - Registers
ParameterNamesModule— lets Jackson infer constructor parameter names without@JsonPropertyon every arg - Picks up any
@Configurationclass withJackson2ObjectMapperBuilderCustomizeryou’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:
Inject the configured mapper into your test — use
@Autowiredin a@SpringBootTest(heavy), or@JsonTest(lightweight, mapper-only context).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:
- 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.
- 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 useparser.readValueAsTree()to read the whole object, then pick fields out. - 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. Whendeserializeis 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 whichObjectMapperparses 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 awhile (END_OBJECT)loop and matching field names by hand. Don’t. Delegate toreadValueAs. 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 aJsonNodeis usually cleaner. - Returning
nullinstead of empty. A deserializer that returnsnullfor the anomalous case pushes an NPE risk into every downstream caller. ReturnFoo.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 throwMismatchedInputExceptionviactxt.reportInputMismatch(...). Both produce error messages that Jackson can decorate with parser position and JSON path — far more useful in logs than your ownIllegalArgumentException.
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:
- You want a Java object to serialise in a non-default JSON shape. A
Moneyrecord that hasamountandcurrencyfields but should serialise as"12.50 USD". - You need to redact fields conditionally. A
Userwhoseemailis included only when a context flag is set. - 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 isnulland never calls your serializer at all, but if@JsonInclude(Include.ALWAYS)is set anywhere up the chain (class-level, mapper-level), yourserializewill be called withnulland crash onvalue.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
@JsonIncludecompatibility — if your custom serializer never emits a value for “empty” cases (e.g., zeroMoney), Jackson’s@JsonInclude(Include.NON_EMPTY)won’t catch that automatically. You need to either emitnullso 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.
| Layer | Where it lives | Blast radius | Use when |
|---|---|---|---|
| Global | ObjectMapper.configure(...), Jackson2ObjectMapperBuilderCustomizer | Every JSON read/write in your app | You genuinely want it everywhere (e.g., FAIL_ON_UNKNOWN_PROPERTIES = false) |
| Per-class | @JsonInclude, @JsonDeserialize, @JsonSerialize on the class | Every field of one class | Class-wide rule with no exceptions (e.g., all fields non-null in serialisation) |
| Per-field | Same annotations on the field | Just that field | You 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_ARRAYandALLOW_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
@interfacewrapper.
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.”



