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.

By the time we’d traced it to root cause, three failure modes had stacked on each other — and I’d learned more about what Jackson’s ObjectMapper actually does (and refuses to do) than I had in five years of casually using it.

What follows is a walkthrough: what Jackson handles for free, what it doesn’t, why one specific class of third-party API will hand you payloads that crash perfectly reasonable-looking code, and how to write a custom JsonDeserializer that’s three lines longer than the broken one but surgical, null-free, and self-documenting.

The bug is the spine of the article. Skip ahead if you only want the Jackson reference.


The setup

A B2B SaaS I worked with shows users a list of in-progress invoices. Each invoice has an amount in some currency; we render that amount in the user’s display currency. The conversion runs per invoice, querying a small third-party FX provider for the rate at the invoice’s due date.

The “in-progress” tab stopped loading. Every other tab worked. The skeleton rows spun forever; eventually the request died:

GET /invoices/search?status=IN_PROGRESS&...
→ 503

The first instinct — “some bug in the in-progress query” — was wrong, and worth saying out loud because it’s how I almost spent an hour in the wrong place.


Why the trace went where it did

A 503 is ambiguous. The first useful question is “which 503 is it?”

  • App-level — your global exception handler maps something to a 503. Open it, see what maps where. In this codebase, query timeouts mapped to 408; only audit and a payment provider used 503. Neither was on this path. So an app-level 503 was ruled out.
  • Gateway-level — the platform (in this case Heroku) returns a 503 when its own router gives up on the request. That’s an H12 Request timeout after 30s. No exception thrown, no Sentry event — which is precisely why the error tracker was empty.

The router log pinned the shape and gave us the only thing we needed:

at=error code=H12 desc="Request timeout"
path="/invoices/search?...status=IN_PROGRESS..."
service=30000ms status=503
request_id=a259dfea-e7cc-e667-41e0-f008eb3e5a79

Exactly 30 seconds. And a request_id to pull the per-request app-side trace.

Embedded takeaway: a 503 that’s a timeout will not be in your error tracker, because nothing throws. The router knows; your app doesn’t. The first move on any “request hangs then 503” is the platform / load-balancer log, not Sentry.

Filtering the app-side trace by that request_id showed a clean, devastating pattern:

[req=a259dfea...]  Getting currency rate for invoice#1234, RUB → USD
[req=a259dfea...]  CurrencyBeacon API call: base=RUB, date=2026-05-22
[req=a259dfea...]  CurrencyBeacon API call: base=RUB, date=2026-05-22   (retry 1, ~2.1s later)
[req=a259dfea...]  CurrencyBeacon API call: base=RUB, date=2026-05-22   (retry 2, ~2.1s later)
[req=a259dfea...]  Currency provider failed for source=RUB ... returning empty rate
[req=a259dfea...]  Getting currency rate for invoice#1235, RUB → USD
[req=a259dfea...]  CurrencyBeacon API call: base=RUB, date=2026-05-19
...

Three calls per invoice (one + two retries), spaced by @Retryable(backoff = 2000ms), each costing ~4.5s. Times nineteen in-progress invoices — all of them happening to be in Russian rubles for a Russian-business tenant.

Nineteen invoices × 4.5 seconds ≫ 30 seconds. Heroku stops waiting. 503.


The smoking gun: an empty array where a map should be

The retry was being triggered by a CurrencyConversionException. Walking back up the stack:

try {
    return objectMapper.readValue(responseBody, CurrencyRatesResponse.class);
} catch (JsonProcessingException e) {
    throw new CurrencyConversionException(
        "Unable to find currency rate " + source + " to " + target + " on date " + date);
}

So Jackson was throwing on the response body, the catch was wrapping it in a CurrencyConversionException, and @Retryable was firing because that exception was in its retry-on list. The first cause of the bug was that the parse was actually failing. Time to see what the body looked like.

Postman, same endpoint, same API key, same RUB → USD pair:

{
  "meta":   { "code": 200, "disclaimer": "..." },
  "response": {
    "date":  "2026-05-22",
    "base":  "RUB",
    "rates": []
  },
  "date":  "2026-05-22",
  "base":  "RUB",
  "rates": []
}

"rates": [] — an empty array, not an empty object.

The Java side expected a Map<String, BigDecimal>. Jackson sees an opening [ token where it expects {, and throws MismatchedInputException: Cannot deserialize value of type java.util.Map from Array value.

A populated rate set — "rates": {"USD": 0.011} — parses fine. That’s why this hid for months. It only failed for currencies the provider had no rates for. In our case: every RUB invoice, because the provider had no Ruble rates at all.


What Jackson handles for free

Now the teaching part. Before we look at the fix, let’s get clear on what an ObjectMapper actually does for you with no configuration.

JSONJava targetWorks out of the box?
{ ... } (object)POJO with matching field names
{ ... } (object)Map<String, V>
[ ... ] (array)List<T> / Set<T> / Collection<T> / T[]
"..." (string)String, enum (by name)
42 / 4.2 (number)numeric primitives, BigDecimal, BigInteger
true / falseboolean / Boolean
nullnull (or the wrapper’s null state)
"2026-06-02" (string)LocalDate, LocalDateTime, Instant, etc.⚠️ Only if JavaTimeModule is registered
omitted in JSONOptional<T> empty⚠️ Only if Jdk8Module is registered

The two ⚠️ rows are the most common surprise to newcomers — LocalDate parsing isn’t “built in”, it’s a separately-shipped module. Spring Boot registers both by default; raw Jackson doesn’t.


What Jackson does NOT handle for free (the gotchas)

This is the table that should be on a poster in every backend office.

InputTargetDefault behaviourOpt-in fix
[] (empty array)Map<K,V> or POJO❌ Throws MismatchedInputExceptionACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT → maps to null
[] (empty array)scalar (String, int, …)❌ Throwssame feature, → null
"" (empty string)object / number❌ ThrowsACCEPT_EMPTY_STRING_AS_NULL_OBJECT → maps to null
"USD" (single value)List<String>❌ ThrowsACCEPT_SINGLE_VALUE_AS_ARRAY → wraps as ["USD"]
42 (number)String❌ Throws (in strict mode)MapperFeature.ALLOW_COERCION_OF_SCALARS
Unknown JSON fieldPOJO without that field❌ Throws in raw JacksonFAIL_ON_UNKNOWN_PROPERTIES = false (flipped by Spring Boot by default)

That last row is a quiet superpower of Spring Boot’s defaults. Without it, every API change a third party makes — adding a new field to a response — would crash your client until you updated your POJOs. Spring’s Jackson2ObjectMapperBuilder flips it to false, so unknown fields are silently ignored. (This is also why, in the failing call above, the parse made it past the meta/response/base/date fields and crashed precisely on rates — the others were fine to ignore; rates was the one we actually mapped.)

The four-bullet summary — what Jackson refuses to do unless you ask:

  • treat an empty array as anything other than an array,
  • coerce a scalar into a list,
  • coerce an empty string into a null object or number,
  • accept unknown fields (raw — Spring Boot already does this for you).

If you remember nothing else, remember: Jackson coerces nothing across structural JSON types unless you opt in. [] is not {}. "" is not null. "USD" is not ["USD"]. The structure has to match.


Why APIs return [] for an empty map (the PHP quirk)

Worth a paragraph because it explains why this bug exists in the first place, and why it’ll keep appearing in your integrations.

PHP’s json_encode has no way to distinguish between an empty list and an empty associative array — both are the same data structure under the hood (array()). The default serialiser falls back to [] for empty containers:

json_encode([])                          // → "[]"  (empty list, fair enough)
json_encode(['USD' => 0.011])            // → "{\"USD\":0.011}"  (populated map, fine)
json_encode($emptyAssocArray)            // → "[]"  ← also this, surprise

So a PHP-backed API that uses an associative array to model a map — entirely natural — will serialise the empty state as [] and the populated state as {}. The shape of the JSON literally depends on whether the data is empty. Strongly-typed clients like Jackson notice; loosely-typed clients (JavaScript, Python dict/list interchangeably, PHP itself) don’t.

If you consume any PHP-backed API, expect this. CurrencyBeacon is one example; you’ll find others. The fix is on your side; the API isn’t going to change.


Two ways to fix this (and one that doesn’t exist)

The honest axis here isn’t “one line vs a few dozen” — it’s global vs scoped.

Option 1 — Global feature flag

objectMapper.configure(
    DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true);

One line on the shared ObjectMapper, and Jackson maps [] to null whenever it sees an array where it expected an object. The hidden cost is in the blast radius: every DTO across the whole application now silently turns [] into null for any object or Map target. If a different integration somewhere in the codebase is relying on [] failing loud to surface a malformed payload, that protection is gone too. The fix is one line; the surface area is the whole app.

Option 2 — Custom deserializer

@JsonDeserialize(using = EmptyArrayTolerantRatesDeserializer.class)
private Map<String, BigDecimal> rates;

A dozen-or-so lines (the deserializer class itself — shown in the next section), but scoped to exactly one field. The intent lives in the class name. Other fields and other integrations continue to fail loud on []-where-{}-was-expected. And the deserializer can choose to return Map.of() rather than null, which makes the downstream contract a little cleaner.

The reflex middle-ground that doesn’t exist

The first thing I reached for was the per-field shorthand — “can’t I just annotate the one field?” Something like:

// won't compile
@JsonFormat(with = JsonFormat.Feature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT)
private Map<String, BigDecimal> rates;

That doesn’t exist. The ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT constant is defined only on DeserializationFeature (the mapper-global enum), not on JsonFormat.Feature (the per-field annotation enum). Jackson genuinely doesn’t ship a per-field opt-in for this particular coercion. The only scoped paths are a custom JsonDeserializer or an equally-custom @JsonSetter method on the class — both of which are custom code in different shapes, not a built-in shortcut.

Why we picked Option 2 — and why my first reason was wrong

My first reach for the deserializer was about “this prevents NPEs everywhere downstream.” A tech-lead reviewer pushed on that, and was right to: getRates() had exactly one production caller. “NPEs everywhere” was rhetorical inflation; one null-guard would have covered it.

But the conclusion — pick the deserializer — survived once we re-framed the axis. It’s not about NPE counts. It’s about blast radius:

Option 1 (global flag)Option 2 (custom deserializer)
Lines of code~2~12
Scope of behaviour changeevery DTO in the appone field
Other integrations affectedyes — every empty-array-where-object-expected is now silently a nullno
Documents the intent at the call sitenoyes (the class name)
Returns empty vs nullnullMap.of()

On those axes, the twelve-line change is the smaller change in terms of system impact, not the larger one. The intuition that “fewer lines = lower gravity” inverts the moment you measure gravity in scope rather than tokens.

Embedded takeaway: when a code-review argument doesn’t survive scrutiny, the right move isn’t to defend it — re-examine the framing. The conclusion can still be correct; the reason might be wrong. “Custom deserialization is gravity” sounded right too, until we noticed the only alternative was a globally-scoped flag.

We picked Option 2.


How to write a custom Jackson deserialiser

The pattern in three beats:

  1. Extend JsonDeserializer<T> for the target type.
  2. Inspect the current token to detect the anomaly.
  3. Delegate to Jackson’s default handling for the normal case — never reimplement map/list parsing.

Here’s the entire file:

/**
 * CurrencyBeacon serialises an empty rate set as a JSON array ([])
 * instead of an empty object ({}). Jackson cannot map an array onto a
 * Map, so without this the client would throw on a perfectly valid
 * "no rate available" response (e.g. base=RUB), which the caller then
 * retries 3×. This deserializer treats an (empty) array as an empty
 * map; a populated object is parsed as a normal map.
 */
public class EmptyArrayTolerantRatesDeserializer
        extends JsonDeserializer<Map<String, BigDecimal>> {

    @Override
    public Map<String, BigDecimal> deserialize(
            JsonParser parser, DeserializationContext context) throws IOException {

        if (parser.currentToken() == JsonToken.START_ARRAY) {
            parser.skipChildren();   // consume the (empty) array
            return Map.of();
        }

        return parser.readValueAs(new TypeReference<Map<String, BigDecimal>>() {});
    }
}

Wire it on the field:

@JsonDeserialize(using = EmptyArrayTolerantRatesDeserializer.class)
private Map<String, BigDecimal> rates;

Four details worth naming:

  • parser.currentToken() is the right hook. When deserialize is called, the parser is positioned at the first token of the value — START_ARRAY for [...], START_OBJECT for {...}. Branch on that.
  • parser.skipChildren() consumes the remaining tokens of the current structure. Even though the array is empty in our case, we still call it — if a malformed payload returned [1, 2, 3], we’d skip cleanly instead of leaving the parser mid-stream.
  • parser.readValueAs(new TypeReference<...>() {}) is the delegation. We don’t try to parse the map ourselves. We hand the parser back to Jackson with the target type and let it do its job. This is the part most copy-pasted examples online get wrong — they hand-roll the map parsing with while (parser.nextToken() != END_OBJECT) loops and silently break on nested types.
  • Annotate the field, not the global mapper. @JsonDeserialize(using = ...) on the field means the deserialiser runs regardless of which ObjectMapper (test, prod, ad-hoc) parses this class. Configuring the global mapper requires that every code path use the configured one — a much weaker guarantee.

The behavioural contract becomes:

  • {} → empty Map ✓ (Jackson default, unchanged)
  • {"USD": 0.011} → populated Map ✓ (delegated to Jackson)
  • [] → empty Map ✓ (our handling)
  • [ {...some object...} ] → empty Map ✓ (we skipChildren() and return empty; arguable, see below)
  • "some string" or 42 → throws ✓ (readValueAs rejects, as it should)

The fourth row is the only debatable one — we could throw on a non-empty array to be stricter. We kept it as “tolerate anything that’s an array” because the PHP quirk only ever produces empty arrays in this position, and being lenient saves a future class of fragile behaviour if the provider’s quirk evolves. Strict and lenient are both defensible; just pick one consciously.


The retry amplifier: why a parse error became a 30-second outage

It’s worth zooming out for a moment. The deserialisation bug, in isolation, costs ~100 milliseconds — one parse attempt, one exception, done. It became a 30-second outage because three other things compounded:

  1. The exception triggers @Retryable. The repository method has retryFor = {CurrencyConversionException.class, ...}. So the failing call doesn’t fail in 100ms — it fails three times over ~4.5 seconds.
  2. The list resolves currency per invoice (N+1). Nineteen invoices, all RUB, each triggering its own currency lookup independently. No batching, no per-currency dedup. Even if every lookup had succeeded in 100ms, this would be slow. With each one taking 4.5s — game over.
  3. Failures aren’t cached. Each invoice’s currency lookup repeats the entire 4.5-second retry cycle, even though we know — from the invoice we just processed — that RUB has no rate today.

Three stacked failure modes. Any one of them in isolation is survivable; the product is 19 × 4.5s = ~85s, which gets cut off at Heroku’s 30-second router timeout.

Embedded takeaway: a fail-safe catch protects you from crashing, not from being slow. The exception handler in the service was wrapping the parse failure cleanly and returning an empty rate — that’s why the request never errored out and Sentry stayed empty. But the time the exception cost was unhidden, sat outside the catch, and stacked up across all nineteen invoices.

The deserialiser fix alone took the timeout from 30 seconds to ~10. Each lookup dropped from 4.5s (3 retries) to ~0.5s (single successful parse). The fix didn’t need to dedup the N+1 or add caching — those are separate, legitimate cleanups, and they’re better as their own PRs. (Always be skeptical of “while we’re here” surface expansion. The PR that ships is the PR scoped to the root cause.)


The testing sidebar: this shipped with green tests

The most uncomfortable part of the post-mortem. The original code had unit tests. They passed. Let me show you why they’re worse than no tests at all:

// The test that "covered" this code path
@Mock private ObjectMapper objectMapper;
@InjectMocks private CurrencyBeaconClient client;

@Test
void throwsCurrencyConversionExceptionWhenParseFails() throws Exception {
    when(objectMapper.readValue(anyString(), eq(CurrencyRatesResponse.class)))
        .thenThrow(new JsonProcessingException("invalid body") {});

    assertThrows(CurrencyConversionException.class,
        () -> client.getHistoricalRate("RUB", "USD", LocalDate.now()));
}

Read it carefully. The ObjectMapper — the thing being tested, the thing whose behaviour matters — is mocked. The mock is stubbed to throw. Then the test asserts that throwing is the expected behaviour. In other words: the bug is encoded as the test’s success condition. Run the test forever; it’ll never tell you the parse is broken, because it never asks the parser anything.

This is the single most common testing antipattern in serialisation code: mocking the component under test.

The fix is to stop mocking the thing you’re trying to verify. For deserialisation, that means: feed real bytes through a real ObjectMapper, configured the same way it’ll be in production:

@Test
void emptyRatesArrayDeserialisesToEmptyMap() throws Exception {
    var mapper = new ObjectMapper()
        .registerModule(new JavaTimeModule())
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    var body = """
        {
          "date":  "2026-05-22",
          "base":  "RUB",
          "rates": []
        }
        """;

    var response = mapper.readValue(body, CurrencyRatesResponse.class);

    assertThat(response.getRates()).isEmpty();
    // crucially: no exception thrown
}

This test would have failed before the deserialiser existed. It would have caught the bug in code review, before production saw a single 503.

Embedded takeaway: don’t mock the component under test. For serialisation in particular, feed real bytes through a real ObjectMapper configured like prod. Unit tests of “what happens when Jackson throws” only verify the catch block — they’re worth far less than one test that asks Jackson to actually parse the payload.


What I’d tell another engineer in one paragraph

A 503 that’s a timeout will not be in your error tracker — find it in the platform router log via the request_id and trace from there. A fail-safe catch protects you from crashing, not from being slow; the time the exception cost still sits outside the catch and stacks. Jackson will happily parse the happy path and refuse the empty one: an empty array is not an empty object, an empty string is not null, a single value is not a one-element list — none of those coerce unless you opt in. When a third-party (especially PHP-backed) API can return an empty collection, write a small custom JsonDeserializer, branch on the current token, delegate the normal case, return empty rather than null, annotate the field. Then test it with real bytes through a real ObjectMapper — never mock the parser you’re trying to harden. The whole bug was three lines of code; finding it was nine layers of trace and one Postman call.

Related Posts

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.

Read more

One Email. Two Invoices. The 10-Month Bug Hiding Behind a Slack Alert.

A Slack alert said “duplicate notification.”

A 2-line fix would have shipped it the same day.

Both were wrong.

Read more

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